From 6a1d81bfa834bdcf641c1182b20f7c350dfd6cb8 Mon Sep 17 00:00:00 2001 From: OCbwoy3 Date: Fri, 15 Aug 2025 16:15:28 +0300 Subject: [PATCH] profile header n shit --- app/layout.tsx | 9 +- app/page.tsx | 41 ++++--- app/test/page.tsx | 5 - app/users/[id]/content.tsx | 42 +++++++ app/users/[id]/page.tsx | 11 ++ bun.lock | 8 ++ components/providers/ReactQueryProvider.tsx | 37 +++++-- components/roblox/FriendCarousel.tsx | 5 +- components/roblox/FriendsOnline.tsx | 2 +- components/roblox/GameCard.tsx | 2 + components/roblox/UserProfileHeader.tsx | 117 ++++++++++++++++++++ components/site/HomeUserHeader.tsx | 25 +++-- components/util/LazyLoadedImage.tsx | 63 +++++++++-- hooks/roblox/useRobuxBalance.ts | 6 +- hooks/roblox/useThumbnails.ts | 17 +++ lib/thumbnailLoader.ts | 22 +++- package.json | 2 + 17 files changed, 352 insertions(+), 62 deletions(-) delete mode 100644 app/test/page.tsx create mode 100644 app/users/[id]/content.tsx create mode 100644 app/users/[id]/page.tsx create mode 100644 components/roblox/UserProfileHeader.tsx create mode 100644 hooks/roblox/useThumbnails.ts diff --git a/app/layout.tsx b/app/layout.tsx index 7f59030..425a958 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -35,15 +35,14 @@ export default function RootLayout({
- -
+ /> */} +
{children} diff --git a/app/page.tsx b/app/page.tsx index c97b18c..82babac 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,30 +12,40 @@ import { getOmniRecommendationsHome, OmniRecommendation } from "@/lib/omniRecommendation"; -import { loadThumbnails } from "@/lib/thumbnailLoader"; -import { useQuery } from "@tanstack/react-query"; +import { getThumbnails, ThumbnailRequest } from "@/lib/thumbnailLoader"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangleIcon } from "lucide-react"; export default function Home() { const SORTS_ALLOWED_IDS = [100000003, 100000001]; + const queryClient = useQueryClient(); - const { data: rec } = useQuery({ + const { data: rec, isLoading } = useQuery({ queryKey: ["omni-recommendations"], queryFn: async () => { const r = await getOmniRecommendationsHome(); if (r) { - loadThumbnails( - Object.entries(r.contentMetadata.Game).map((a) => ({ - type: "GameThumbnail", - targetId: Number(a[1].rootPlaceId), - format: "webp", - size: "384x216" - })) - ).catch((a) => {}); + // Prefetch game thumbnails into React Query cache + const gameRequests: ThumbnailRequest[] = Object.entries( + r.contentMetadata.Game + ).map(([_, g]) => ({ + type: "GameThumbnail" as const, + targetId: Number(g.rootPlaceId), + format: "webp", + size: "384x216" + })); + + await queryClient.prefetchQuery({ + queryKey: [ + "thumbnails", + gameRequests.map((r) => r.targetId) + ], + queryFn: () => getThumbnails(gameRequests) + }); } return r; }, - staleTime: 300000, // 5 minutes + staleTime: 1000 * 60 * 5, // 5 minutes refetchOnWindowFocus: false }); @@ -50,18 +60,19 @@ export default function Home() { Warning - This is work in progess, you can follow the development + This is work in progress, you can follow the development process on GitHub.
+
- {!rec ? ( + {isLoading || !rec ? (
- {"Loading..."} + Loading...
diff --git a/app/test/page.tsx b/app/test/page.tsx deleted file mode 100644 index 927f9ff..0000000 --- a/app/test/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -"use client"; - -export default function Page() { - return <>hi; -} diff --git a/app/users/[id]/content.tsx b/app/users/[id]/content.tsx new file mode 100644 index 0000000..e9d5696 --- /dev/null +++ b/app/users/[id]/content.tsx @@ -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
Loading user profile...
; + if (!profile) notFound(); + + return ( +
+ + +
+ {profile.description} +
+
+ ); +} diff --git a/app/users/[id]/page.tsx b/app/users/[id]/page.tsx new file mode 100644 index 0000000..ed3e8d1 --- /dev/null +++ b/app/users/[id]/page.tsx @@ -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 ( + Loading profile…
}> + + + ); +} diff --git a/bun.lock b/bun.lock index d6022ef..0247246 100644 --- a/bun.lock +++ b/bun.lock @@ -35,8 +35,10 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/line-clamp": "^0.4.4", + "@tanstack/query-async-storage-persister": "^5.85.3", "@tanstack/react-query": "^5.85.3", "@tanstack/react-query-devtools": "^5.85.3", + "@tanstack/react-query-persist-client": "^5.85.3", "@types/bun": "^1.2.19", "class-variance-authority": "^0.7.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=="], + "@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-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-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/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], diff --git a/components/providers/ReactQueryProvider.tsx b/components/providers/ReactQueryProvider.tsx index 65cbab6..91f4bc3 100644 --- a/components/providers/ReactQueryProvider.tsx +++ b/components/providers/ReactQueryProvider.tsx @@ -1,19 +1,42 @@ "use client"; +import { ReactNode, useEffect } from "react"; 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 { useState } from "react"; -export function ReactQueryProvider({ - children -}: { - children: React.ReactNode; -}) { - const [queryClient] = useState(() => new QueryClient()); +interface Props { + children: ReactNode; +} + +export function ReactQueryProvider({ children }: Props) { + 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 ( {children} + ); } diff --git a/components/roblox/FriendCarousel.tsx b/components/roblox/FriendCarousel.tsx index e72221b..644a1e7 100644 --- a/components/roblox/FriendCarousel.tsx +++ b/components/roblox/FriendCarousel.tsx @@ -4,6 +4,7 @@ import React, { useMemo } from "react"; import LazyLoadedImage from "../util/LazyLoadedImage"; import { StupidHoverThing } from "../util/MiscStuff"; import { VerifiedIcon } from "./RobloxIcons"; +import Link from "next/link"; export function FriendCarousel({ friends: friendsUnsorted, @@ -122,7 +123,8 @@ export function FriendCarousel({
} > -
+ +
+ ); })} diff --git a/components/roblox/FriendsOnline.tsx b/components/roblox/FriendsOnline.tsx index f6523fc..6e80dcb 100644 --- a/components/roblox/FriendsOnline.tsx +++ b/components/roblox/FriendsOnline.tsx @@ -11,7 +11,7 @@ export function FriendsHomeSect( ) { const friends = useFriendsHome(); - return ; + return friends && ; } export function BestFriendsHomeSect( diff --git a/components/roblox/GameCard.tsx b/components/roblox/GameCard.tsx index 444aa63..97aaa54 100644 --- a/components/roblox/GameCard.tsx +++ b/components/roblox/GameCard.tsx @@ -29,6 +29,8 @@ export const GameCard = React.memo(function GameCard({ game }: GameCardProps) { } alt={game.name} className="object-fill w-full h-full" + lazyFetch={false} // ALWAYS fetch immediately + size="384x216" // match game thumbnail size /> ) : (
diff --git a/components/roblox/UserProfileHeader.tsx b/components/roblox/UserProfileHeader.tsx new file mode 100644 index 0000000..46627de --- /dev/null +++ b/components/roblox/UserProfileHeader.tsx @@ -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 ( +
+ + + Failed to fetch account info + + Is it a React Query bug? + + +
+ ); + } + + 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 ( + <> + {/* */} +
{ + 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 ? ( + + ) : ( + + )} +
+ + {isLoaded ? ( + + {user.displayName} + + ) : ( + <> + + + )} + {isLoaded && user.hasVerifiedBadge ? ( + + ) : ( + <> + )} + + + {isLoaded ? ( + <> + @{user.name} + {!!userActivity && userPresence === 2 ? ( + <> - {userActivity.lastLocation} + ) : ( + <> + )} + + ) : ( + + )} + +
+
+ + ); +} diff --git a/components/site/HomeUserHeader.tsx b/components/site/HomeUserHeader.tsx index 1a32dd3..983566a 100644 --- a/components/site/HomeUserHeader.tsx +++ b/components/site/HomeUserHeader.tsx @@ -14,6 +14,7 @@ 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"; // chatgpt + human function randomGreeting(name: string): string { @@ -50,12 +51,12 @@ export function HomeLoggedInHeader() { 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"; + ? "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 = !!profile && !!accountSettings; @@ -91,11 +92,13 @@ export function HomeLoggedInHeader() {
{isLoaded ? ( - randomGreeting( - window.localStorage.UserPreferredName || - profile.displayName || - "Robloxian!" - ) + + {randomGreeting( + window.localStorage.UserPreferredName || + profile.displayName || + "Robloxian!" + )} + ) : ( <> diff --git a/components/util/LazyLoadedImage.tsx b/components/util/LazyLoadedImage.tsx index 90a7416..003b0e6 100644 --- a/components/util/LazyLoadedImage.tsx +++ b/components/util/LazyLoadedImage.tsx @@ -1,33 +1,78 @@ -import React from "react"; -import { useThumbnailURL } from "@/hooks/use-lazy-load"; +"use client"; + +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 Image from "next/image"; interface LazyLoadedImageProps { imgId: 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 = ({ imgId, alt, + width = 1024, + height = 1024, + className, + lazyFetch = true, + size = "48x48", ...props }) => { - const imgUrl = useThumbnailURL(imgId); + const [isVisible, setIsVisible] = useState(!lazyFetch); + const ref = useRef(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 ( -
- {imgUrl ? ( +
+ {imgUrl && !isLoading ? ( {alt} ) : ( - + )}
); diff --git a/hooks/roblox/useRobuxBalance.ts b/hooks/roblox/useRobuxBalance.ts index e1e4420..1f1672a 100644 --- a/hooks/roblox/useRobuxBalance.ts +++ b/hooks/roblox/useRobuxBalance.ts @@ -12,15 +12,15 @@ export function useRobuxBalance() { const { data: robux } = useQuery({ queryKey: ["robux-balance", acct ? acct.id : "acctId"], queryFn: async () => { - if (!acct) return null; + if (!acct) return 0; try { const res = await proxyFetch( `https://economy.roblox.com/v1/users/${acct.id}/currency` ); const data = await res.json(); - return data.robux; + return data.robux || 0; } catch { - return false; + return 0; } }, enabled: !!acct, diff --git a/hooks/roblox/useThumbnails.ts b/hooks/roblox/useThumbnails.ts new file mode 100644 index 0000000..b9bc379 --- /dev/null +++ b/hooks/roblox/useThumbnails.ts @@ -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({ + queryKey: ["thumbnails", requests.map((r) => r.targetId)], + queryFn: () => getThumbnails(requests), + staleTime: 1000 * 60 * 5, // 5 minutes + enabled: false + }); +} diff --git a/lib/thumbnailLoader.ts b/lib/thumbnailLoader.ts index eb4d36e..87e5547 100644 --- a/lib/thumbnailLoader.ts +++ b/lib/thumbnailLoader.ts @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { addThumbnail } from "@/hooks/use-lazy-load"; import { proxyFetch } from "./utils"; @@ -28,6 +29,7 @@ export async function getThumbnails( ): Promise { const batchSize = 100; const results: AssetThumbnail[] = []; + for (let i = 0; i < b.length; i += batchSize) { const batch = b.slice(i, i + batchSize); const data = await proxyFetch( @@ -49,19 +51,29 @@ export async function getThumbnails( } } ); + const json = await data.json(); 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)!; - addThumbnail(ty.type + "_" + a.targetId.toString(), a.imageUrl); + const ty = b.find((c) => c.targetId === a.targetId)!; + addThumbnail(`${ty.type}_${a.targetId}`, a.imageUrl); }); + results.push(...(json.data as AssetThumbnail[])); } + 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 { const th = await getThumbnails(b); } - -// https://apis.roblox.com/discovery-api/omni-recommendation diff --git a/package.json b/package.json index ac15c84..c39d535 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,10 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/line-clamp": "^0.4.4", + "@tanstack/query-async-storage-persister": "^5.85.3", "@tanstack/react-query": "^5.85.3", "@tanstack/react-query-devtools": "^5.85.3", + "@tanstack/react-query-persist-client": "^5.85.3", "@types/bun": "^1.2.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1",