react query ftw

This commit is contained in:
2025-08-14 22:46:42 +03:00
parent 78f792578d
commit 502a25fe52
24 changed files with 365 additions and 462 deletions

View File

@@ -1,7 +1,7 @@
// chatgpt // chatgpt
function rewriteCookieDomain(rawCookie: string): string { function rewriteCookieDomain(rawCookie: string): string {
return rawCookie return rawCookie
.replace(/;?\s*Domain=[^;]+/i, '') .replace(/;?\s*Domain=[^;]+/i, "")
.concat(`; Domain=localhost:3000`); .concat(`; Domain=localhost:3000`);
} }

View File

@@ -5,6 +5,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import Image from "next/image"; import Image from "next/image";
import { QuickTopUI, QuickTopUILogoPart } from "@/components/site/QuickTopUI"; import { QuickTopUI, QuickTopUILogoPart } from "@/components/site/QuickTopUI";
import { ReactQueryProvider } from "@/components/providers/ReactQueryProvider";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -31,24 +32,26 @@ export default function RootLayout({
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-x-hidden`} className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-x-hidden`}
> >
<TooltipProvider> <ReactQueryProvider>
<main> <TooltipProvider>
<Image <main>
/* window.localStorage.BgImageUrl */ <Image
src={"/bg.png"} /* window.localStorage.BgImageUrl */
width={1920} src={"/bg.png"}
height={1080} width={1920}
className="w-screen h-screen bg-blend-hard-light fixed top-0 left-0 blur-lg opacity-25" height={1080}
alt="" className="w-screen h-screen bg-blend-hard-light fixed top-0 left-0 blur-lg opacity-25"
/> alt=""
<div className="z-10 isolate overflow-scroll no-scrollbar w-screen max-h-screen h-screen antialiased overflow-x-hidden"> />
<QuickTopUI /> <div className="z-10 isolate overflow-scroll no-scrollbar w-screen max-h-screen h-screen antialiased overflow-x-hidden">
<QuickTopUILogoPart /> <QuickTopUI />
{children} <QuickTopUILogoPart />
</div> {children}
</main> </div>
<Toaster /> </main>
</TooltipProvider> <Toaster />
</TooltipProvider>
</ReactQueryProvider>
</body> </body>
</html> </html>
); );

View File

@@ -13,17 +13,17 @@ import {
OmniRecommendation OmniRecommendation
} from "@/lib/omniRecommendation"; } from "@/lib/omniRecommendation";
import { loadThumbnails } from "@/lib/thumbnailLoader"; import { loadThumbnails } from "@/lib/thumbnailLoader";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangleIcon } from "lucide-react"; import { AlertTriangleIcon } from "lucide-react";
import { useEffect, useState } from "react";
export default function Home() { export default function Home() {
const SORTS_ALLOWED_IDS = [100000003, 100000001]; const SORTS_ALLOWED_IDS = [100000003, 100000001];
const [rec, setRec] = useState<OmniRecommendation | null>(null);
useEffect(() => { const { data: rec } = useQuery({
setTimeout(async () => { queryKey: ["omni-recommendations"],
queryFn: async () => {
const r = await getOmniRecommendationsHome(); const r = await getOmniRecommendationsHome();
if (r) { if (r) {
setRec(r);
loadThumbnails( loadThumbnails(
Object.entries(r.contentMetadata.Game).map((a) => ({ Object.entries(r.contentMetadata.Game).map((a) => ({
type: "GameThumbnail", type: "GameThumbnail",
@@ -33,8 +33,11 @@ export default function Home() {
})) }))
).catch((a) => {}); ).catch((a) => {});
} }
}, 1000); return r;
}, []); },
staleTime: 300000, // 5 minutes
refetchOnWindowFocus: false
});
return ( return (
<> <>

View File

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

View File

@@ -35,6 +35,8 @@
"@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/react-query": "^5.85.3",
"@tanstack/react-query-devtools": "^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",
@@ -285,6 +287,14 @@
"@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-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/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=="],
"@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

@@ -0,0 +1,22 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
export function ReactQueryProvider({
children
}: {
children: React.ReactNode;
}) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
);
}

View File

@@ -1,7 +1,6 @@
import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount"; import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount";
import { useFriendsHome } from "@/hooks/roblox/useFriends";
import { useFriendsPresence } from "@/hooks/roblox/usePresence"; import { useFriendsPresence } from "@/hooks/roblox/usePresence";
import React, { useEffect, useState } from "react"; 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";
@@ -29,78 +28,32 @@ export function FriendCarousel({
}) { }) {
const acct = useCurrentAccount(); const acct = useCurrentAccount();
const presence = useFriendsPresence( const presence = useFriendsPresence(
(!!friendsUnsorted ? friendsUnsorted : []).map((f) => f.id) (friendsUnsorted || []).map((f) => f.id)
); );
const [friendsLabel, setFriendsLabel] = useState<string>(""); const friends = useMemo(() => {
if (!friendsUnsorted) return [];
const [friends, setFriends] = useState< return [...friendsUnsorted].sort((a, b) => {
{ if (dontSortByActivity) return -10;
hasVerifiedBadge: boolean;
id: number;
name: string;
displayName: string;
}[]
>([]);
useEffect(() => { const userStatusA = presence.find((c) => c.userId === a.id);
let numStudio = 0; const userStatusB = presence.find((c) => c.userId === b.id);
let numGame = 0;
let numOnline = 0;
for (const friend of friendsUnsorted || []) {
const st = presence.find((c) => c.userId === friend.id);
switch (st?.userPresenceType || 0) {
case 1:
numOnline += 1;
break;
case 2:
numGame += 1;
break;
case 3:
numStudio += 1;
break;
}
}
setFriendsLabel(
[
// `${friends.length}`,
(numOnline+numGame+numStudio === 0 || numOnline === 0) ? null : `${numOnline+numGame+numStudio} online`,
numGame === 0 ? null : `${numGame} in-game`,
] return (
.filter((a) => !!a) (userStatusB?.userPresenceType || 0) -
.join(" | ") (userStatusA?.userPresenceType || 0)
); );
});
if (!friendsUnsorted) {
setFriends([]);
return;
}
setFriends(
friendsUnsorted.sort((a, b) => {
if (!!dontSortByActivity) return -10;
const userStatusA = presence.find((c) => c.userId === a.id);
const userStatusB = presence.find((c) => c.userId === b.id);
return (
(userStatusB?.userPresenceType || 0) -
(userStatusA?.userPresenceType || 0)
);
})
);
}, [friendsUnsorted, presence, dontSortByActivity]); }, [friendsUnsorted, presence, dontSortByActivity]);
if (!friends || friends.length === 0) { if (friends.length === 0) {
return <></>; return null;
} }
return ( return (
<div {...props}> <div {...props}>
{/* <button onClick={()=>console.log(acct,presence,friends)}>debug</button> */} <h1 className="text-2xl pt-4 pl-4 -mb-4">{title}</h1>
<h1 className="text-2xl pt-4 pl-4 -mb-4">
{title}{" "}
<span className="text-overlay1 text-sm pl-2">{friendsLabel}</span>
</h1>
<div className="rounded-xl flex flex-col gap-2 px-4 no-scrollbar"> <div className="rounded-xl flex flex-col gap-2 px-4 no-scrollbar">
<div <div
className="flex p-8 items-center gap-4 overflow-x-auto overflow-y-visible no-scrollbar pb-2 -mx-4 w-screen scrollbar-thin scrollbar-thumb-surface2 scrollbar-track-surface0" className="flex p-8 items-center gap-4 overflow-x-auto overflow-y-visible no-scrollbar pb-2 -mx-4 w-screen scrollbar-thin scrollbar-thumb-surface2 scrollbar-track-surface0"
@@ -110,7 +63,6 @@ export function FriendCarousel({
scrollbarWidth: "none" scrollbarWidth: "none"
}} }}
> >
{/* <div className="w-8" /> */}
{friends.map((a) => { {friends.map((a) => {
const userStatus = presence.find( const userStatus = presence.find(
(b) => b.userId === a.id (b) => b.userId === a.id
@@ -161,15 +113,16 @@ export function FriendCarousel({
className={`w-4 h-4 shrink-0`} className={`w-4 h-4 shrink-0`}
/> />
) : null} ) : null}
{ userPresence >= 2 ? <p>{userStatus?.lastLocation}</p> : <></>} {userPresence >= 2 ? (
<p>
{userStatus?.lastLocation}
</p>
) : null}
</span> </span>
</div> </div>
} }
> >
<div <div className="flex flex-col min-w-[6.5rem]">
key={a.id}
className="flex flex-col min-w-[6.5rem]"
>
<LazyLoadedImage <LazyLoadedImage
imgId={`AvatarHeadShot_${a.id}`} imgId={`AvatarHeadShot_${a.id}`}
alt={a.name} alt={a.name}
@@ -191,7 +144,6 @@ export function FriendCarousel({
</StupidHoverThing> </StupidHoverThing>
); );
})} })}
{/* <div className="w-8" /> */}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -22,5 +22,12 @@ export function BestFriendsHomeSect(
) { ) {
const friends = useBestFriends(); const friends = useBestFriends();
return <FriendCarousel {...props} title="Best Friends" dontSortByActivity friends={friends} />; return (
<FriendCarousel
{...props}
title="Best Friends"
dontSortByActivity
friends={friends}
/>
);
} }

View File

@@ -1,9 +1,5 @@
import { PremiumIconSmall, VerifiedIcon } from "./RobloxIcons"; import { PremiumIconSmall, VerifiedIcon } from "./RobloxIcons";
import { import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
Tooltip,
TooltipContent,
TooltipTrigger
} from "../ui/tooltip";
export function RobloxPremiumSmall(props: React.SVGProps<SVGSVGElement>) { export function RobloxPremiumSmall(props: React.SVGProps<SVGSVGElement>) {
return ( return (

View File

@@ -17,9 +17,7 @@ import { toast } from "sonner";
// chatgpt + human // chatgpt + human
function randomGreeting(name: string): string { function randomGreeting(name: string): string {
const greetings = [ const greetings = [`Howdy, ${name}`];
`Howdy, ${name}`
];
const index = Math.floor(Math.random() * greetings.length); const index = Math.floor(Math.random() * greetings.length);
return greetings[index]; return greetings[index];
@@ -52,12 +50,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;
@@ -92,7 +90,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 ? randomGreeting(window.localStorage.UserPreferredName || profile.displayName || "Robloxian!") : ( {isLoaded ? (
randomGreeting(
window.localStorage.UserPreferredName ||
profile.displayName ||
"Robloxian!"
)
) : (
<> <>
<Skeleton className="w-96 h-8 rounded-lg" /> <Skeleton className="w-96 h-8 rounded-lg" />
</> </>

View File

@@ -10,10 +10,16 @@ import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount";
type OutfitSelectorProps = { type OutfitSelectorProps = {
setVisible: (visible: boolean) => void; setVisible: (visible: boolean) => void;
updateOutfit: (outfit: { id: number }, acc: {id: number}) => Promise<void>; updateOutfit: (
outfit: { id: number },
acc: { id: number }
) => Promise<void>;
}; };
export function OutfitSelector({ setVisible, updateOutfit }: OutfitSelectorProps) { export function OutfitSelector({
setVisible,
updateOutfit
}: OutfitSelectorProps) {
const outfits = useAvatarOutfits(); const outfits = useAvatarOutfits();
const acc = useCurrentAccount(); const acc = useCurrentAccount();
@@ -33,7 +39,7 @@ export function OutfitSelector({ setVisible, updateOutfit }: OutfitSelectorProps
key={outfit.id} key={outfit.id}
className="hover:bg-base/50 rounded-lg" className="hover:bg-base/50 rounded-lg"
onClick={async () => { onClick={async () => {
updateOutfit(outfit,acc); updateOutfit(outfit, acc);
setVisible(false); setVisible(false);
}} }}
> >

View File

@@ -79,7 +79,6 @@ async function updateOutfit(outfit: { id: number }, acc: { id: number }) {
} }
); );
loadThumbnails([ loadThumbnails([
{ {
type: "AvatarHeadShot", type: "AvatarHeadShot",
@@ -98,7 +97,7 @@ export const QuickTopUI = React.memo(function () {
const bf = useBestFriends(); const bf = useBestFriends();
useCurrentAccount(); useCurrentAccount();
useFriendsPresence([...(f ? f : []), ...(bf ? bf : [])].map(a=>a.id)) useFriendsPresence([...(f ? f : []), ...(bf ? bf : [])].map((a) => a.id));
const robux = useRobuxBalance(); const robux = useRobuxBalance();
const [isOutfitSelectorVisible, setIsOutfitSelectorVisible] = const [isOutfitSelectorVisible, setIsOutfitSelectorVisible] =
@@ -154,9 +153,12 @@ export const QuickTopUILogoPart = React.memo(function () {
<Link href="/" className="-m-1 w-8 h-8"> <Link href="/" className="-m-1 w-8 h-8">
<img src="/icon-512.webp" className="w-8 h-8" alt="" /> <img src="/icon-512.webp" className="w-8 h-8" alt="" />
</Link> </Link>
<Link href="/test" className="mt-2 gap-2 flex items-center"> <Link href="/" className="mt-2 gap-2 flex items-center">
<p>{"ocbwoy3-chan's roblox"}</p> <p>{"ocbwoy3-chan's roblox"}</p>
<p className="text-surface2 line-clamp-1">{process.env.NODE_ENV} {process.env.NEXT_PUBLIC_CWD} {process.env.NEXT_PUBLIC_ARGV0}</p> {/* <p className="text-surface2 line-clamp-1">
{process.env.NODE_ENV} {process.env.NEXT_PUBLIC_CWD}{" "}
{process.env.NEXT_PUBLIC_ARGV0}
</p> */}
</Link> </Link>
</div> </div>
); );

View File

@@ -2,15 +2,18 @@ import { TooltipProps } from "@radix-ui/react-tooltip";
import { VerifiedIcon } from "../roblox/RobloxIcons"; import { VerifiedIcon } from "../roblox/RobloxIcons";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function StupidHoverThing({ children, text, ...props }: React.PropsWithChildren & TooltipProps & { text: string | React.ReactNode }) { export function StupidHoverThing({
children,
text,
...props
}: React.PropsWithChildren &
TooltipProps & { text: string | React.ReactNode }) {
return ( return (
<Tooltip {...props}> <Tooltip {...props}>
<TooltipTrigger asChild> <TooltipTrigger asChild>{children}</TooltipTrigger>
{children}
</TooltipTrigger>
<TooltipContent className="bg-surface0 text-text m-2"> <TooltipContent className="bg-surface0 text-text m-2">
<span className="text-sm flex items-center">{text}</span> <span className="text-sm flex items-center">{text}</span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) );
} }

View File

@@ -1,67 +1,84 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCurrentAccount } from "./useCurrentAccount"; import { useCurrentAccount } from "./useCurrentAccount";
import { proxyFetch } from "@/lib/utils"; import { proxyFetch } from "@/lib/utils";
import { useEffect } from "react";
type AccountSettings = { type AccountSettings = {
ChangeUsernameEnabled: boolean ChangeUsernameEnabled: boolean;
/* determines if the account owner is a roblox admin */ /* determines if the account owner is a roblox admin */
IsAdmin: boolean, IsAdmin: boolean;
PreviousUserNames: string, PreviousUserNames: string;
/* censored out email */ /* censored out email */
UserEmail: string, UserEmail: string;
UserAbove13: boolean, UserAbove13: boolean;
/* does the user have roblox premium */ /* does the user have roblox premium */
IsPremium: boolean, IsPremium: boolean;
/* ingame chat */ /* ingame chat */
IsGameChatSettingEnabled: boolean IsGameChatSettingEnabled: boolean;
} };
export function useAccountSettings() { export function useAccountSettings() {
const acct = useCurrentAccount(); const acct = useCurrentAccount();
const [accountSettings, setAccountSettings] = useState<AccountSettings | false | null>(null); const queryClient = useQueryClient();
useEffect(() => { const { data: accountSettings } = useQuery<AccountSettings | false | null>({
if (!acct) return; queryKey: ["account-settings", acct ? acct.id : "acctId"],
queryFn: async () => {
let cancelled = false; if (!acct) return null;
const fetchSetttings = async () => {
if (!acct || cancelled) return;
try { try {
const res = await proxyFetch( const res = await proxyFetch(
`https://www.roblox.com/my/settings/json` `https://www.roblox.com/my/settings/json`
); );
if (!res.ok) {
console.error(
`[useAccountSettings] API Error ${res.status} ${res.statusText}`
);
return false;
}
const data = await res.json(); const data = await res.json();
if (!cancelled) setAccountSettings(data); return data;
} catch { } catch (error) {
if (!cancelled) setAccountSettings(false); console.error(
"[useAccountSettings] Failed to fetch settings",
error
);
return false;
} }
}; },
enabled: !!acct,
fetchSetttings(); staleTime: Infinity,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false
});
useEffect(() => {
const handleTransaction = () => { const handleTransaction = () => {
fetchSetttings(); queryClient.invalidateQueries({
queryKey: ["account-settings", acct ? acct.id : "acctId"]
});
}; };
window.addEventListener("settingTransactionCompletedEvent", handleTransaction); window.addEventListener(
"settingTransactionCompletedEvent",
handleTransaction
);
return () => { return () => {
cancelled = true;
window.removeEventListener( window.removeEventListener(
"settingTransactionCompletedEvent", "settingTransactionCompletedEvent",
handleTransaction handleTransaction
); );
}; };
}, [acct]); }, [acct ? acct.id : "acctId", queryClient]);
return accountSettings; return accountSettings;
} }

View File

@@ -1,63 +1,64 @@
// https://avatar.roblox.com/v2/avatar/users/1083030325/outfits?isEditable=true&itemsPerPage=50&outfitType=Avatar
"use client"; "use client";
import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useCurrentAccount } from "./useCurrentAccount"; import { useCurrentAccount } from "./useCurrentAccount";
import { proxyFetch } from "@/lib/utils"; import { proxyFetch } from "@/lib/utils";
import { loadThumbnails } from "@/lib/thumbnailLoader"; import { loadThumbnails } from "@/lib/thumbnailLoader";
type Outfit = { type Outfit = {
name: string, name: string;
id: number id: number;
} };
export function useAvatarOutfits() { export function useAvatarOutfits(): Outfit[] | false | null {
const acct = useCurrentAccount(); const acct = useCurrentAccount();
const [outfits, setOutfits] = useState<Outfit[] | false | null>(null); const queryClient = useQueryClient();
const query = useQuery<Outfit[] | false>({
queryKey: ["avatarOutfits", acct ? acct.id : "acctId"],
enabled: !!acct,
queryFn: async () => {
if (!acct) return false;
const res = await proxyFetch(
`https://avatar.roblox.com/v2/avatar/users/${acct.id}/outfits?page=1&itemsPerPage=25&isEditable=true`
);
const data = (await res.json()) as { data: Outfit[] };
loadThumbnails(
data.data.map((a) => ({
type: "Outfit",
targetId: a.id,
format: "webp",
size: "420x420"
}))
).catch(() => {});
return data.data;
},
staleTime: 1000 * 60 * 5,
refetchOnWindowFocus: false
});
useEffect(() => { useEffect(() => {
if (!acct) return;
let cancelled = false;
const fetchSetttings = async () => {
if (!acct || cancelled) return;
try {
const res = await proxyFetch(
`https://avatar.roblox.com/v2/avatar/users/${acct.id}/outfits?page=1&itemsPerPage=25&isEditable=true`
);
const data = await res.json() as {data: Outfit[]};
if (!cancelled) {
setOutfits(data.data);
loadThumbnails(data.data.map(a=>({
type: "Outfit",
targetId: a.id,
format: "webp",
size: "420x420"
}))).catch(a=>{})
}
} catch {
if (!cancelled) setOutfits(false);
}
};
fetchSetttings();
const handleTransaction = () => { const handleTransaction = () => {
fetchSetttings(); queryClient.invalidateQueries({
queryKey: ["avatarOutfits", acct ? acct.id : "acctId"]
});
}; };
window.addEventListener("avatarTransactionCompletedEvent", handleTransaction); window.addEventListener(
"avatarTransactionCompletedEvent",
handleTransaction
);
return () => { return () => {
cancelled = true;
window.removeEventListener( window.removeEventListener(
"avatarTransactionCompletedEvent", "avatarTransactionCompletedEvent",
handleTransaction handleTransaction
); );
}; };
}, [acct]); }, [acct ? acct.id : "acctId", queryClient]);
return outfits; return query.data ?? null;
} }

View File

@@ -1,46 +1,41 @@
"use client"; "use client";
// https://friends.roblox.com/v1/users/1083030325/friends/find?userSort=1 import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useCurrentAccount } from "./useCurrentAccount";
import { proxyFetch } from "@/lib/utils"; import { proxyFetch } from "@/lib/utils";
import { loadThumbnails } from "@/lib/thumbnailLoader"; import { loadThumbnails } from "@/lib/thumbnailLoader";
import { useCurrentAccount } from "./useCurrentAccount";
let isFetching = false; export function useBestFriends():
let cachedData: any = null; | {
hasVerifiedBadge: boolean;
export function useBestFriends() { id: number;
name: string;
displayName: string;
}[]
| null
| false {
const acct = useCurrentAccount(); const acct = useCurrentAccount();
const [friends, setFriends] = useState<
const query = useQuery<
| { | {
hasVerifiedBadge: boolean; hasVerifiedBadge: boolean;
id: number; id: number;
name: string; name: string;
displayName: string; displayName: string;
}[] }[]
| null
| false | false
>(cachedData); >({
queryKey: ["bestFriends", acct ? acct.id : "acctId"],
enabled: !!acct,
queryFn: async () => {
if (!acct) return false;
const BestFriendIDs = JSON.parse(
window.localStorage.getItem("BestFriendsStore") || "[]"
) as number[];
if (BestFriendIDs.length === 0) return [];
useEffect(() => {
let cancelled = false;
if (!acct) return;
if (isFetching) {
const IN = setInterval(() => {
if (cachedData !== null) {
if (!cancelled) setFriends(cachedData);
clearInterval(IN);
}
}, 50);
return () => {
clearInterval(IN);
cancelled = true;
};
}
isFetching = true;
(async () => {
const BestFriendIDs = JSON.parse(window.localStorage.getItem("BestFriendsStore") || "[]") as number[]
const friendsAPICall2 = await proxyFetch( const friendsAPICall2 = await proxyFetch(
`https://users.roblox.com/v1/users`, `https://users.roblox.com/v1/users`,
{ {
@@ -51,6 +46,7 @@ export function useBestFriends() {
}) })
} }
); );
const J2 = (await friendsAPICall2.json()) as { const J2 = (await friendsAPICall2.json()) as {
data: { data: {
hasVerifiedBadge: boolean; hasVerifiedBadge: boolean;
@@ -59,6 +55,7 @@ export function useBestFriends() {
displayName: string; displayName: string;
}[]; }[];
}; };
loadThumbnails( loadThumbnails(
J2.data.map((a) => ({ J2.data.map((a) => ({
type: "AvatarHeadShot", type: "AvatarHeadShot",
@@ -67,7 +64,8 @@ export function useBestFriends() {
format: "webp" format: "webp"
})) }))
).catch(() => {}); ).catch(() => {});
const friendsList = BestFriendIDs.map((a) => {
return BestFriendIDs.map((a) => {
const x = J2.data.find((b) => b.id === a); const x = J2.data.find((b) => b.id === a);
return { return {
id: a, id: a,
@@ -76,14 +74,10 @@ export function useBestFriends() {
displayName: x?.displayName || "?" displayName: x?.displayName || "?"
}; };
}); });
if (!cancelled) setFriends(friendsList); },
cachedData = friendsList; staleTime: 1000 * 60 * 5,
isFetching = false; refetchOnWindowFocus: false
})(); });
return () => {
cancelled = true;
};
}, [acct]);
return friends; return query.data ?? null;
} }

View File

@@ -1,61 +1,36 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query";
import { import {
getLoggedInUser, getLoggedInUser,
getUserByUserId, getUserByUserId,
UserProfileDetails UserProfileDetails
} from "@/lib/profile"; } from "@/lib/profile";
import { loadThumbnails } from "@/lib/thumbnailLoader"; import { loadThumbnails } from "@/lib/thumbnailLoader";
import { useEffect, useState } from "react";
let isFetching = false; export function useCurrentAccount(): UserProfileDetails | null | false {
let cachedData: UserProfileDetails | null | false = null; const query = useQuery<UserProfileDetails | false>({
queryKey: ["currentAccount"],
export function useCurrentAccount() { queryFn: async () => {
const [profileDetails, setProfileDetails] = useState<
UserProfileDetails | null | false
>(cachedData);
useEffect(() => {
let cancelled = false;
if (profileDetails !== null && profileDetails !== undefined) return;
if (isFetching) {
const IN = setInterval(() => {
if (cachedData !== null) {
if (!cancelled) setProfileDetails(cachedData);
clearInterval(IN);
}
}, 50);
return () => {
clearInterval(IN);
cancelled = true;
};
}
isFetching = true;
(async () => {
const authed = await getLoggedInUser(); const authed = await getLoggedInUser();
if (authed) { if (!authed) return false;
const user = await getUserByUserId(authed.id.toString());
if (!cancelled) setProfileDetails(user);
cachedData = user;
loadThumbnails([
{
type: "AvatarHeadShot",
targetId: authed.id,
format: "webp",
size: "720x720"
}
]).catch(() => {});
} else {
if (!cancelled) setProfileDetails(false);
cachedData = false;
}
isFetching = false;
})();
return () => {
cancelled = true;
};
}, [profileDetails]);
return profileDetails; const user = await getUserByUserId(authed.id.toString());
loadThumbnails([
{
type: "AvatarHeadShot",
targetId: authed.id,
format: "webp",
size: "720x720"
}
]).catch(() => {});
return user;
},
staleTime: 1000 * 60 * 5,
refetchOnWindowFocus: false
});
return query.data ?? null;
} }

View File

@@ -1,64 +1,34 @@
"use client"; "use client";
// https://friends.roblox.com/v1/users/1083030325/friends/find?userSort=1 import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useCurrentAccount } from "./useCurrentAccount"; import { useCurrentAccount } from "./useCurrentAccount";
import { proxyFetch } from "@/lib/utils"; import { proxyFetch } from "@/lib/utils";
import { loadThumbnails } from "@/lib/thumbnailLoader"; import { loadThumbnails } from "@/lib/thumbnailLoader";
import { UserProfileDetails } from "@/lib/profile";
let isFetching = false;
let cachedData: any = null;
export function useFriendsHome() { export function useFriendsHome() {
const acct = useCurrentAccount(); const acct = useCurrentAccount();
const [friends, setFriends] = useState< const { data: friends } = useQuery({
| { queryKey: ["friends", acct ? acct.id : "acctId"],
hasVerifiedBadge: boolean; queryFn: async () => {
id: number; if (!acct) return null;
name: string;
displayName: string;
}[]
| null
| false
>(cachedData);
useEffect(() => {
let cancelled = false;
if (!acct) return;
if (isFetching) {
const IN = setInterval(() => {
if (cachedData !== null) {
if (!cancelled) setFriends(cachedData);
clearInterval(IN);
}
}, 50);
return () => {
clearInterval(IN);
cancelled = true;
};
}
isFetching = true;
(async () => {
const friendsAPICall = await proxyFetch( const friendsAPICall = await proxyFetch(
`https://friends.roblox.com/v1/users/${acct.id}/friends` // /find?userSort=1 `https://friends.roblox.com/v1/users/${acct.id}/friends`
); );
const J = (await friendsAPICall.json()) as { const j = (await friendsAPICall.json()) as {
data: { id: number }[]; data: { id: number }[];
// PageItems: { id: number }[]; // /find
}; };
const friendsAPICall2 = await proxyFetch( const friendsAPICall2 = await proxyFetch(
`https://users.roblox.com/v1/users`, `https://users.roblox.com/v1/users`,
{ {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
userIds: J.data.map((a) => a.id), userIds: j.data.map((a) => a.id),
// userIds: J.PageItems.map((a) => a.id),
excludeBannedUsers: false excludeBannedUsers: false
}) })
} }
); );
const J2 = (await friendsAPICall2.json()) as { const j2 = (await friendsAPICall2.json()) as {
data: { data: {
hasVerifiedBadge: boolean; hasVerifiedBadge: boolean;
id: number; id: number;
@@ -67,15 +37,15 @@ export function useFriendsHome() {
}[]; }[];
}; };
loadThumbnails( loadThumbnails(
J2.data.map((a) => ({ j2.data.map((a) => ({
type: "AvatarHeadShot", type: "AvatarHeadShot",
size: "420x420", size: "420x420",
targetId: a.id, targetId: a.id,
format: "webp" format: "webp"
})) }))
).catch(() => {}); ).catch(() => {});
const friendsList = J.data.map((a) => { // J.PageItems /find const friendsList = j.data.map((a) => {
const x = J2.data.find((b) => b.id === a.id); const x = j2.data.find((b) => b.id === a.id);
return { return {
id: a.id, id: a.id,
hasVerifiedBadge: x?.hasVerifiedBadge || false, hasVerifiedBadge: x?.hasVerifiedBadge || false,
@@ -83,14 +53,14 @@ export function useFriendsHome() {
displayName: x?.displayName || "?" displayName: x?.displayName || "?"
}; };
}); });
if (!cancelled) setFriends(friendsList); return friendsList;
cachedData = friendsList; },
isFetching = false; enabled: !!acct,
})(); staleTime: 300000, // 5 minutes
return () => { refetchOnWindowFocus: false,
cancelled = true; refetchOnMount: false,
}; refetchOnReconnect: false
}, [acct]); });
return friends; return friends;
} }

View File

@@ -1,8 +1,6 @@
"use client"; "use client";
// smartass method by google gemini import { useQuery } from "@tanstack/react-query";
import { useEffect, useState, useMemo } from "react";
import { useCurrentAccount } from "./useCurrentAccount"; import { useCurrentAccount } from "./useCurrentAccount";
import { proxyFetch } from "@/lib/utils"; import { proxyFetch } from "@/lib/utils";
@@ -16,114 +14,53 @@ type PresenceData = {
userId: number; userId: number;
}; };
// --- Internal Shared State ---
/**
* A Map to track subscribers.
* Key: The component's update callback function.
* Value: The array of user IDs that component is interested in.
* This allows multiple components to subscribe with their own lists of IDs.
*/
let subscribers = new Map<(data: PresenceData[]) => void, number[]>();
let interval: ReturnType<typeof setInterval> | null = null;
let latestData: PresenceData[] = [];
/**
* Fetches presence for all unique user IDs requested by all subscribed components.
* @param acctId - The ID of the currently logged-in user.
*/
async function fetchPresence(acctId: number) {
const allIdArrays = [...subscribers.values()];
const uniqueUserIds = [...new Set(allIdArrays.flat())];
if (!acctId || uniqueUserIds.length === 0) {
return;
}
try {
const res = await proxyFetch(
"https://presence.roblox.com/v1/presence/users",
{
method: "POST",
body: JSON.stringify({
userIds: [...new Set([acctId, ...uniqueUserIds])]
}),
headers: {
"Content-Type": "application/json"
}
}
);
if (!res.ok) {
console.log(`[usePresence] API Error ${res.status} ${res.statusText}`)
return
// throw new Error(`API request failed with status ${res.status}`);
}
const json = await res.json();
latestData = json.userPresences || [];
subscribers.forEach((_requestedIds, callback) => callback(latestData));
} catch (error) {
console.error("Failed to fetch presence:", error);
latestData = [];
subscribers.forEach((_requestedIds, callback) => callback([]));
}
}
/** /**
* A React hook to get the real-time presence of a list of Roblox users. * A React hook to get the real-time presence of a list of Roblox users.
* This hook can be used by multiple components simultaneously without conflict. * This hook uses @tanstack/react-query to handle caching and periodic refetching.
* *
* @param userIds - An array of user IDs to track. * @param userIds - An array of user IDs to track.
* @returns An array of PresenceData objects for the requested user IDs. * @returns An array of PresenceData objects for the requested user IDs.
*/ */
export function useFriendsPresence(userIds: number[]) { export function useFriendsPresence(userIds: number[]) {
const acct = useCurrentAccount(); const acct = useCurrentAccount();
const [data, setData] = useState<PresenceData[]>([]);
const userIdsKey = useMemo( // Sort userIds to ensure the query key is stable, regardless of the order of IDs.
() => JSON.stringify([...userIds].sort()), const sortedUserIds = [...(userIds || [])].sort();
[userIds]
);
useEffect(() => { const { data: presences = [] } = useQuery({
if (!acct || !userIds || userIds.length === 0) { queryKey: ["presence", ...sortedUserIds],
setData([]); queryFn: async () => {
return; if (!acct || sortedUserIds.length === 0) {
} return [];
const updateCallback = (globalData: PresenceData[]) => {
const filteredData = globalData.filter((presence) =>
userIds.includes(presence.userId)
);
setData(filteredData);
};
updateCallback(latestData);
subscribers.set(updateCallback, userIds);
if (!interval) {
fetchPresence(acct.id);
interval = setInterval(() => fetchPresence(acct.id), 5000);
} else {
fetchPresence(acct.id);
}
// The cleanup function runs when the component unmounts.
return () => {
subscribers.delete(updateCallback);
if (subscribers.size === 0 && interval) {
clearInterval(interval);
interval = null;
latestData = [];
} }
};
}, [acct, userIdsKey]);
return data; const res = await proxyFetch(
"https://presence.roblox.com/v1/presence/users",
{
method: "POST",
body: JSON.stringify({
userIds: sortedUserIds
}),
headers: {
"Content-Type": "application/json"
}
}
);
if (!res.ok) {
console.error(
`[usePresence] API Error ${res.status} ${res.statusText}`
);
throw new Error(`API request failed with status ${res.status}`);
}
const json = await res.json();
return (json.userPresences || []) as PresenceData[];
},
enabled: !!acct && sortedUserIds.length > 0,
refetchInterval: 5000,
refetchOnWindowFocus: false
});
return presences;
} }

View File

@@ -1,49 +1,49 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCurrentAccount } from "./useCurrentAccount"; import { useCurrentAccount } from "./useCurrentAccount";
import { proxyFetch } from "@/lib/utils"; import { proxyFetch } from "@/lib/utils";
import { useEffect } from "react";
export function useRobuxBalance() { export function useRobuxBalance() {
const acct = useCurrentAccount(); const acct = useCurrentAccount();
const [robux, setRobux] = useState<number | false | null>(null); const queryClient = useQueryClient();
useEffect(() => { const { data: robux } = useQuery<number | false | null>({
if (!acct) return; queryKey: ["robux-balance", acct ? acct.id : "acctId"],
queryFn: async () => {
let cancelled = false; if (!acct) return null;
const fetchBalance = async () => {
if (!acct || cancelled) return;
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();
if (!cancelled) setRobux(data.robux); return data.robux;
} catch { } catch {
if (!cancelled) setRobux(false); return false;
} }
}; },
enabled: !!acct,
fetchBalance(); refetchInterval: 10000,
const interval = setInterval(fetchBalance, 10000); staleTime: 10000
});
useEffect(() => {
const handleTransaction = () => { const handleTransaction = () => {
fetchBalance(); queryClient.invalidateQueries({
queryKey: ["robux-balance", acct ? acct.id : "acctId"]
});
}; };
window.addEventListener("transactionCompletedEvent", handleTransaction); window.addEventListener("transactionCompletedEvent", handleTransaction);
return () => { return () => {
cancelled = true;
clearInterval(interval);
window.removeEventListener( window.removeEventListener(
"transactionCompletedEvent", "transactionCompletedEvent",
handleTransaction handleTransaction
); );
}; };
}, [acct]); }, [acct ? acct.id : "acctId", queryClient]);
return robux; return robux;
} }

View File

@@ -242,7 +242,7 @@ export function findClosestBrickColor(hex: string): {
col: [number, number, number]; col: [number, number, number];
} { } {
const target = hexToRgb(hex); const target = hexToRgb(hex);
console.log(hex,target) console.log(hex, target);
if (!target) throw new Error("Invalid hex"); if (!target) throw new Error("Invalid hex");
let bestDist = Infinity; let bestDist = Infinity;

View File

@@ -28,7 +28,7 @@ export async function proxyFetchRaw(
...init, ...init,
method: init?.method || "GET", method: init?.method || "GET",
headers, headers,
body: init?.body, body: init?.body
}; };
return window.fetch(proxyUrl, fetchInit); return window.fetch(proxyUrl, fetchInit);
@@ -57,7 +57,7 @@ export async function proxyFetch(
response = await proxyFetchRaw(input, { response = await proxyFetchRaw(input, {
...init, ...init,
headers: newHeaders, headers: newHeaders
}); });
} }

View File

@@ -1,13 +1,14 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
if (!process.isBun) { if (!process.isBun) {
console.error(`You are running this with node. Rerun the process: bun --bun run dev`) console.error(
process.exit(1) `You are running this with node. Rerun the process: bun --bun run dev`
);
process.exit(1);
} }
process.env.NEXT_PUBLIC_CWD = __dirname || "~" process.env.NEXT_PUBLIC_CWD = __dirname || "~";
process.env.NEXT_PUBLIC_ARGV0 = process.argv0 || "node" process.env.NEXT_PUBLIC_ARGV0 = process.argv0 || "node";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */

View File

@@ -40,6 +40,8 @@
"@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/react-query": "^5.85.3",
"@tanstack/react-query-devtools": "^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",