profile header n shit

This commit is contained in:
2025-08-15 16:15:28 +03:00
parent aec0052ef5
commit 6a1d81bfa8
17 changed files with 352 additions and 62 deletions

View File

@@ -35,15 +35,14 @@ export default function RootLayout({
<ReactQueryProvider> <ReactQueryProvider>
<TooltipProvider> <TooltipProvider>
<main> <main>
<Image {/* <Image
/* window.localStorage.BgImageUrl */
src={"/bg.png"} src={"/bg.png"}
width={1920} width={1920}
height={1080} height={1080}
className="w-screen h-screen bg-blend-hard-light fixed top-0 left-0 blur-lg opacity-25" className="w-screen h-screen bg-blend-hard-light fixed top-0 left-0 opacity-25"
alt="" alt=""
/> /> */}
<div className="z-10 isolate overflow-scroll no-scrollbar w-screen max-h-screen h-screen antialiased overflow-x-hidden"> <div className="backdrop-blur-lg z-10 isolate overflow-scroll no-scrollbar w-screen max-h-screen h-screen antialiased overflow-x-hidden">
<QuickTopUI /> <QuickTopUI />
<QuickTopUILogoPart /> <QuickTopUILogoPart />
{children} {children}

View File

@@ -12,30 +12,40 @@ import {
getOmniRecommendationsHome, getOmniRecommendationsHome,
OmniRecommendation OmniRecommendation
} from "@/lib/omniRecommendation"; } from "@/lib/omniRecommendation";
import { loadThumbnails } from "@/lib/thumbnailLoader"; import { getThumbnails, ThumbnailRequest } from "@/lib/thumbnailLoader";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangleIcon } from "lucide-react"; import { AlertTriangleIcon } from "lucide-react";
export default function Home() { export default function Home() {
const SORTS_ALLOWED_IDS = [100000003, 100000001]; const SORTS_ALLOWED_IDS = [100000003, 100000001];
const queryClient = useQueryClient();
const { data: rec } = useQuery({ const { data: rec, isLoading } = useQuery({
queryKey: ["omni-recommendations"], queryKey: ["omni-recommendations"],
queryFn: async () => { queryFn: async () => {
const r = await getOmniRecommendationsHome(); const r = await getOmniRecommendationsHome();
if (r) { if (r) {
loadThumbnails( // Prefetch game thumbnails into React Query cache
Object.entries(r.contentMetadata.Game).map((a) => ({ const gameRequests: ThumbnailRequest[] = Object.entries(
type: "GameThumbnail", r.contentMetadata.Game
targetId: Number(a[1].rootPlaceId), ).map(([_, g]) => ({
format: "webp", type: "GameThumbnail" as const,
size: "384x216" targetId: Number(g.rootPlaceId),
})) format: "webp",
).catch((a) => {}); size: "384x216"
}));
await queryClient.prefetchQuery({
queryKey: [
"thumbnails",
gameRequests.map((r) => r.targetId)
],
queryFn: () => getThumbnails(gameRequests)
});
} }
return r; return r;
}, },
staleTime: 300000, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false refetchOnWindowFocus: false
}); });
@@ -50,18 +60,19 @@ export default function Home() {
<AlertTriangleIcon /> <AlertTriangleIcon />
<AlertTitle>Warning</AlertTitle> <AlertTitle>Warning</AlertTitle>
<AlertDescription> <AlertDescription>
This is work in progess, you can follow the development This is work in progress, you can follow the development
process on GitHub. process on GitHub.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</div> </div>
<div className="p-4 space-y-8 no-scrollbar"> <div className="p-4 space-y-8 no-scrollbar">
{!rec ? ( {isLoading || !rec ? (
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="h-[200px] flex items-center justify-center"> <div className="h-[200px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground"> <div className="animate-pulse text-muted-foreground">
{"Loading..."} Loading...
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,5 +0,0 @@
"use client";
export default function Page() {
return <>hi</>;
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { notFound } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { getUserByUserId } from "@/lib/profile";
import { UserProfileHeader } from "@/components/roblox/UserProfileHeader";
interface UserProfileContentProps {
userId: string;
}
export default function UserProfileContent({
userId
}: UserProfileContentProps) {
const { data: profile, isLoading } = useQuery({
queryKey: ["user-profile", userId],
queryFn: () => getUserByUserId(userId),
enabled: !!userId
});
// Set dynamic document title
useEffect(() => {
if (profile?.displayName) {
document.title = `${profile.displayName}'s profile | ocbwoy3-chan's roblox`;
}
}, [profile]);
if (isLoading) return <div className="p-4">Loading user profile...</div>;
if (!profile) notFound();
return (
<div className="p-4 space-y-6">
<UserProfileHeader user={profile} />
<Separator />
<div className="break-all whitespace-normal">
{profile.description}
</div>
</div>
);
}

11
app/users/[id]/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { Suspense } from "react";
import UserProfileContent from "./content";
// page.tsx (Server Component)
export default async function UserProfilePage({ params }: { params: { id: string } }) {
return (
<Suspense fallback={<div className="p-4">Loading profile</div>}>
<UserProfileContent userId={(await params).id} />
</Suspense>
);
}

View File

@@ -35,8 +35,10 @@
"@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/line-clamp": "^0.4.4", "@tailwindcss/line-clamp": "^0.4.4",
"@tanstack/query-async-storage-persister": "^5.85.3",
"@tanstack/react-query": "^5.85.3", "@tanstack/react-query": "^5.85.3",
"@tanstack/react-query-devtools": "^5.85.3", "@tanstack/react-query-devtools": "^5.85.3",
"@tanstack/react-query-persist-client": "^5.85.3",
"@types/bun": "^1.2.19", "@types/bun": "^1.2.19",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -287,14 +289,20 @@
"@tailwindcss/line-clamp": ["@tailwindcss/line-clamp@0.4.4", "", { "peerDependencies": { "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" } }, "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g=="], "@tailwindcss/line-clamp": ["@tailwindcss/line-clamp@0.4.4", "", { "peerDependencies": { "tailwindcss": ">=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1" } }, "sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g=="],
"@tanstack/query-async-storage-persister": ["@tanstack/query-async-storage-persister@5.85.3", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.85.3" } }, "sha512-lcyIZBMuW7iI1oJCvhQoOinouzl1kd9Fkc9rHwgO/D7Y1sfeDNU7PdMgGAEYc1MZelU84A6LotzcND56iXTxfw=="],
"@tanstack/query-core": ["@tanstack/query-core@5.85.3", "", {}, "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ=="], "@tanstack/query-core": ["@tanstack/query-core@5.85.3", "", {}, "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ=="],
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.84.0", "", {}, "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.84.0", "", {}, "sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ=="],
"@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.85.3", "", { "dependencies": { "@tanstack/query-core": "5.85.3" } }, "sha512-b6PfSvBbxr1ZGk1bt6aysJr3RcQBkWDzj8HtZUFHDSvSBzWipRtPYPpvJBPwmWKkVPjIKUo40lBsEA0drRgvvA=="],
"@tanstack/react-query": ["@tanstack/react-query@5.85.3", "", { "dependencies": { "@tanstack/query-core": "5.85.3" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ=="], "@tanstack/react-query": ["@tanstack/react-query@5.85.3", "", { "dependencies": { "@tanstack/query-core": "5.85.3" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ=="],
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.85.3", "", { "dependencies": { "@tanstack/query-devtools": "5.84.0" }, "peerDependencies": { "@tanstack/react-query": "^5.85.3", "react": "^18 || ^19" } }, "sha512-WSVweCE1Kh1BVvPDHAmLgGT+GGTJQ9+a7bVqzD+zUiUTht+salJjYm5nikpMNaHFPJV102TCYdvgHgBXtURRNg=="], "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.85.3", "", { "dependencies": { "@tanstack/query-devtools": "5.84.0" }, "peerDependencies": { "@tanstack/react-query": "^5.85.3", "react": "^18 || ^19" } }, "sha512-WSVweCE1Kh1BVvPDHAmLgGT+GGTJQ9+a7bVqzD+zUiUTht+salJjYm5nikpMNaHFPJV102TCYdvgHgBXtURRNg=="],
"@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.85.3", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.85.3" }, "peerDependencies": { "@tanstack/react-query": "^5.85.3", "react": "^18 || ^19" } }, "sha512-FiQ2zHGwtWMeBt3elkINcxKxiO3FM/U3Q6fZXEIWiZGuoS9DK8WV1uEwOgb66ZyzI8z2OyJzLkePSD82fGD5Rw=="],
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],

View File

@@ -1,19 +1,42 @@
"use client"; "use client";
import { ReactNode, useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function ReactQueryProvider({ interface Props {
children children: ReactNode;
}: { }
children: React.ReactNode;
}) { export function ReactQueryProvider({ children }: Props) {
const [queryClient] = useState(() => new QueryClient()); const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1
}
}
});
useEffect(() => {
// Persist to localStorage (safe, runs client-side)
const localStoragePersister = createAsyncStoragePersister({
storage: window.localStorage
});
persistQueryClient({
queryClient,
persister: localStoragePersister,
maxAge: 1000 * 60 * 60 // 1 hour max
});
}, [window || "wtf"]);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
<ReactQueryDevtools initialIsOpen={false} client={queryClient} />
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@@ -4,6 +4,7 @@ import React, { useMemo } from "react";
import LazyLoadedImage from "../util/LazyLoadedImage"; import LazyLoadedImage from "../util/LazyLoadedImage";
import { StupidHoverThing } from "../util/MiscStuff"; import { StupidHoverThing } from "../util/MiscStuff";
import { VerifiedIcon } from "./RobloxIcons"; import { VerifiedIcon } from "./RobloxIcons";
import Link from "next/link";
export function FriendCarousel({ export function FriendCarousel({
friends: friendsUnsorted, friends: friendsUnsorted,
@@ -122,7 +123,8 @@ export function FriendCarousel({
</div> </div>
} }
> >
<div className="flex flex-col min-w-[6.5rem]"> <Link href={`/users/${a.id}`}>
<div className="flex flex-col min-w-[6.5rem]">
<LazyLoadedImage <LazyLoadedImage
imgId={`AvatarHeadShot_${a.id}`} imgId={`AvatarHeadShot_${a.id}`}
alt={a.name} alt={a.name}
@@ -141,6 +143,7 @@ export function FriendCarousel({
) : null} ) : null}
</span> </span>
</div> </div>
</Link>
</StupidHoverThing> </StupidHoverThing>
); );
})} })}

View File

@@ -11,7 +11,7 @@ export function FriendsHomeSect(
) { ) {
const friends = useFriendsHome(); const friends = useFriendsHome();
return <FriendCarousel {...props} title="Friends" friends={friends} />; return friends && <FriendCarousel {...props} title="Friends" friends={friends} />;
} }
export function BestFriendsHomeSect( export function BestFriendsHomeSect(

View File

@@ -29,6 +29,8 @@ export const GameCard = React.memo(function GameCard({ game }: GameCardProps) {
} }
alt={game.name} alt={game.name}
className="object-fill w-full h-full" className="object-fill w-full h-full"
lazyFetch={false} // ALWAYS fetch immediately
size="384x216" // match game thumbnail size
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center bg-muted"> <div className="w-full h-full flex items-center justify-center bg-muted">

View File

@@ -0,0 +1,117 @@
"use client";
import React, { useEffect } from "react";
import LazyLoadedImage from "../util/LazyLoadedImage";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { OctagonXIcon } from "lucide-react";
import {
RobloxPremiumSmall,
RobloxVerifiedSmall
} from "@/components/roblox/RobloxTooltips";
import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount";
import { Skeleton } from "../ui/skeleton";
import { useFriendsPresence } from "@/hooks/roblox/usePresence";
import { useAccountSettings } from "@/hooks/roblox/useAccountSettings";
import { loadThumbnails } from "@/lib/thumbnailLoader";
import { toast } from "sonner";
import Link from "next/link";
import { UserProfileDetails } from "@/lib/profile";
export function UserProfileHeader({ user }: { user: UserProfileDetails }) {
if (!user) {
return (
<div className="justify-center w-screen px-8 py-6">
<Alert variant="destructive" className="bg-base/50 space-x-2">
<OctagonXIcon />
<AlertTitle>Failed to fetch account info</AlertTitle>
<AlertDescription>
Is it a React Query bug?
</AlertDescription>
</Alert>
</div>
);
}
const presence = useFriendsPresence(user ? [user.id] : []);
const userActivity = presence.find((b) => b.userId === user?.id);
const userPresence = userActivity?.userPresenceType;
const borderColor =
userPresence === 1
? "border-blue/25 bg-blue/25"
: userPresence === 2
? "border-green/25 bg-green/25"
: userPresence === 3
? "border-yellow/25 bg-yellow/25"
: userPresence === 0
? "border-surface2/25 bg-surface2/25"
: "border-red/25 bg-red/25";
const isLoaded = !!user;
return (
<>
{/* <button onClick={()=>console.log(userPresence)}>debug this</button> */}
<div
className="flex items-center gap-6 rounded-xl px-8 py-6 w-fit mt-8 ml-0"
onContextMenu={(e) => {
if (e.button === 2) {
toast("[debug] reloading user pfp");
console.log("[debug] reloading user pfp");
loadThumbnails([
{
type: "AvatarHeadShot",
targetId: user ? user.id : 1,
format: "webp",
size: "720x720"
}
]).catch(() => {});
}
}}
>
{!isLoaded ? (
<Skeleton className="w-28 h-28 rounded-full" />
) : (
<LazyLoadedImage
imgId={`AvatarHeadShot_${user.id}`}
alt=""
className={`w-28 h-28 rounded-full shadow-crust border-2 ${borderColor}`}
/>
)}
<div className="flex flex-col justify-center">
<span className="text-3xl font-bold text-text flex items-center gap-2">
{isLoaded ? (
<Link href={`/users/${user.id}`}>
{user.displayName}
</Link>
) : (
<>
<Skeleton className="w-96 h-8 rounded-lg" />
</>
)}
{isLoaded && user.hasVerifiedBadge ? (
<RobloxVerifiedSmall className="w-6 h-6 fill-blue text-base" />
) : (
<></>
)}
</span>
<span className="text-base font-mono text-subtext0 mt-1">
{isLoaded ? (
<>
@{user.name}
{!!userActivity && userPresence === 2 ? (
<> - {userActivity.lastLocation}</>
) : (
<></>
)}
</>
) : (
<Skeleton className="w-64 h-6 rounded-lg" />
)}
</span>
</div>
</div>
</>
);
}

View File

@@ -14,6 +14,7 @@ import { useFriendsPresence } from "@/hooks/roblox/usePresence";
import { useAccountSettings } from "@/hooks/roblox/useAccountSettings"; import { useAccountSettings } from "@/hooks/roblox/useAccountSettings";
import { loadThumbnails } from "@/lib/thumbnailLoader"; import { loadThumbnails } from "@/lib/thumbnailLoader";
import { toast } from "sonner"; import { toast } from "sonner";
import Link from "next/link";
// chatgpt + human // chatgpt + human
function randomGreeting(name: string): string { function randomGreeting(name: string): string {
@@ -50,12 +51,12 @@ export function HomeLoggedInHeader() {
userPresence === 1 userPresence === 1
? "border-blue/25 bg-blue/25" ? "border-blue/25 bg-blue/25"
: userPresence === 2 : userPresence === 2
? "border-green/25 bg-green/25" ? "border-green/25 bg-green/25"
: userPresence === 3 : userPresence === 3
? "border-yellow/25 bg-yellow/25" ? "border-yellow/25 bg-yellow/25"
: userPresence === 0 : userPresence === 0
? "border-surface2/25 bg-surface2/25" ? "border-surface2/25 bg-surface2/25"
: "border-red/25 bg-red/25"; : "border-red/25 bg-red/25";
const isLoaded = !!profile && !!accountSettings; const isLoaded = !!profile && !!accountSettings;
@@ -91,11 +92,13 @@ export function HomeLoggedInHeader() {
<div className="flex flex-col justify-center"> <div className="flex flex-col justify-center">
<span className="text-3xl font-bold text-text flex items-center gap-2"> <span className="text-3xl font-bold text-text flex items-center gap-2">
{isLoaded ? ( {isLoaded ? (
randomGreeting( <Link href={`/users/${profile.id}`}>
window.localStorage.UserPreferredName || {randomGreeting(
profile.displayName || window.localStorage.UserPreferredName ||
"Robloxian!" profile.displayName ||
) "Robloxian!"
)}
</Link>
) : ( ) : (
<> <>
<Skeleton className="w-96 h-8 rounded-lg" /> <Skeleton className="w-96 h-8 rounded-lg" />

View File

@@ -1,33 +1,78 @@
import React from "react"; "use client";
import { useThumbnailURL } from "@/hooks/use-lazy-load";
import React, { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getThumbnails, ThumbnailRequest } from "@/lib/thumbnailLoader";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import Image from "next/image"; import Image from "next/image";
interface LazyLoadedImageProps { interface LazyLoadedImageProps {
imgId: string; imgId: string;
alt: string; alt: string;
[prop: string]: string; width?: number;
height?: number;
className?: string;
lazyFetch?: boolean; // true for avatars, false for game thumbnails
size?: string; // optional fetch size
} }
const LazyLoadedImage: React.FC<LazyLoadedImageProps> = ({ const LazyLoadedImage: React.FC<LazyLoadedImageProps> = ({
imgId, imgId,
alt, alt,
width = 1024,
height = 1024,
className,
lazyFetch = true,
size = "48x48",
...props ...props
}) => { }) => {
const imgUrl = useThumbnailURL(imgId); const [isVisible, setIsVisible] = useState(!lazyFetch);
const ref = useRef<HTMLDivElement>(null);
const [type, targetIdStr] = imgId.split("_");
const targetId = Number(targetIdStr);
// React Query to fetch thumbnail when visible or immediately if lazyFetch=false
const { data: thumbnails, isLoading } = useQuery({
queryKey: ["thumbnails", targetId, size],
queryFn: async () => {
const result = await getThumbnails([
{ type: type as any, targetId, format: "webp", size }
]);
return result;
},
enabled: isVisible,
staleTime: 60_000 * 60 * 60 // 1 hour
});
const imgUrl = thumbnails?.[0]?.imageUrl;
// IntersectionObserver only used if lazyFetch=true
useEffect(() => {
if (!lazyFetch || !ref.current) return;
const observer = new IntersectionObserver(
([entry]) => setIsVisible(entry.isIntersecting),
{ rootMargin: "200px" }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [lazyFetch]);
return ( return (
<div> <div ref={ref}>
{imgUrl ? ( {imgUrl && !isLoading ? (
<Image <Image
src={imgUrl as any} src={imgUrl as any}
width={1024} width={width}
height={1024} height={height}
alt={alt} alt={alt}
className={className}
{...props} {...props}
/> />
) : ( ) : (
<Skeleton {...props} /> <Skeleton className={className} />
)} )}
</div> </div>
); );

View File

@@ -12,15 +12,15 @@ export function useRobuxBalance() {
const { data: robux } = useQuery<number | false | null>({ const { data: robux } = useQuery<number | false | null>({
queryKey: ["robux-balance", acct ? acct.id : "acctId"], queryKey: ["robux-balance", acct ? acct.id : "acctId"],
queryFn: async () => { queryFn: async () => {
if (!acct) return null; if (!acct) return 0;
try { try {
const res = await proxyFetch( const res = await proxyFetch(
`https://economy.roblox.com/v1/users/${acct.id}/currency` `https://economy.roblox.com/v1/users/${acct.id}/currency`
); );
const data = await res.json(); const data = await res.json();
return data.robux; return data.robux || 0;
} catch { } catch {
return false; return 0;
} }
}, },
enabled: !!acct, enabled: !!acct,

View File

@@ -0,0 +1,17 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
getThumbnails,
ThumbnailRequest,
AssetThumbnail
} from "@/lib/thumbnailLoader";
export function useThumbnails(requests: ThumbnailRequest[]) {
return useQuery<AssetThumbnail[], Error>({
queryKey: ["thumbnails", requests.map((r) => r.targetId)],
queryFn: () => getThumbnails(requests),
staleTime: 1000 * 60 * 5, // 5 minutes
enabled: false
});
}

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import { addThumbnail } from "@/hooks/use-lazy-load"; import { addThumbnail } from "@/hooks/use-lazy-load";
import { proxyFetch } from "./utils"; import { proxyFetch } from "./utils";
@@ -28,6 +29,7 @@ export async function getThumbnails(
): Promise<AssetThumbnail[]> { ): Promise<AssetThumbnail[]> {
const batchSize = 100; const batchSize = 100;
const results: AssetThumbnail[] = []; const results: AssetThumbnail[] = [];
for (let i = 0; i < b.length; i += batchSize) { for (let i = 0; i < b.length; i += batchSize) {
const batch = b.slice(i, i + batchSize); const batch = b.slice(i, i + batchSize);
const data = await proxyFetch( const data = await proxyFetch(
@@ -49,19 +51,29 @@ export async function getThumbnails(
} }
} }
); );
const json = await data.json(); const json = await data.json();
json.data.forEach((a: AssetThumbnail) => { json.data.forEach((a: AssetThumbnail) => {
// match GameThumbnail from 4972273297::GameThumbnail:384x216:webp:regular and any like- string const ty = b.find((c) => c.targetId === a.targetId)!;
const ty = b.find((c) => c.targetId == a.targetId)!; addThumbnail(`${ty.type}_${a.targetId}`, a.imageUrl);
addThumbnail(ty.type + "_" + a.targetId.toString(), a.imageUrl);
}); });
results.push(...(json.data as AssetThumbnail[])); results.push(...(json.data as AssetThumbnail[]));
} }
return results; return results;
} }
// React Query hook for caching
export function useThumbnails(requests: ThumbnailRequest[]) {
return useQuery({
queryKey: ["thumbnails", requests.map((r) => r.targetId)],
queryFn: () => getThumbnails(requests),
staleTime: 1000 * 60 * 5 // 5 minutes
});
}
// Optional helper to load without a hook
export async function loadThumbnails(b: ThumbnailRequest[]): Promise<void> { export async function loadThumbnails(b: ThumbnailRequest[]): Promise<void> {
const th = await getThumbnails(b); const th = await getThumbnails(b);
} }
// https://apis.roblox.com/discovery-api/omni-recommendation

View File

@@ -40,8 +40,10 @@
"@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7", "@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/line-clamp": "^0.4.4", "@tailwindcss/line-clamp": "^0.4.4",
"@tanstack/query-async-storage-persister": "^5.85.3",
"@tanstack/react-query": "^5.85.3", "@tanstack/react-query": "^5.85.3",
"@tanstack/react-query-devtools": "^5.85.3", "@tanstack/react-query-devtools": "^5.85.3",
"@tanstack/react-query-persist-client": "^5.85.3",
"@types/bun": "^1.2.19", "@types/bun": "^1.2.19",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",