This commit is contained in:
2025-09-09 09:34:35 +03:00
parent 6a1d81bfa8
commit 3612ada03a
22 changed files with 285 additions and 54 deletions

View File

@@ -0,0 +1,54 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { usePlaceDetails } from "@/hooks/roblox/usePlaceDetails";
import { RobloxVerifiedSmall } from "@/components/roblox/RobloxTooltips";
import { Button } from "@/components/ui/button";
interface GamePageContentProps {
placeId: string;
}
export default function GamePageContent({ placeId }: GamePageContentProps) {
const game = usePlaceDetails(placeId);
// Set dynamic document title
useEffect(() => {
if (!!game) {
document.title = `${game.name} | ocbwoy3-chan's roblox`;
}
}, [game]);
if (!game) return <div className="p-4">Loading game...</div>;
return (
<div className="p-4 space-y-6">
<Button onClick={a=>open(`roblox://placeId=${game.rootPlaceId}`)}>
PLAY
</Button>
<div className="break-all pl-4 whitespace-pre-line font-black text-2xl">
{game.name}
</div>
<div className="break-all pl-4 whitespace-pre-line font-bold flex">
<Link
href={`https://roblox.com/${
game.creator.type === "Group" ? "groups" : "user"
}/${game.creator.id}`}
className="flex"
>
<span className="underline">
{game.creator.name}
</span>
{game.creator.hasVerifiedBadge && (
<RobloxVerifiedSmall className="text-base fill-blue w-4 h-4" />
)}
</Link>
</div>
<div className="break-all pl-4 whitespace-pre-line">
{game.description}
</div>
</div>
);
}

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

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

View File

@@ -3,7 +3,11 @@
@tailwind utilities;
body {
font-family: Geist;
font-family: SF Pro Display, Geist;
}
.font-super-mono {
font-family: SF Mono, Geist Mono;
}
@layer base {

View File

@@ -42,8 +42,8 @@ export default function RootLayout({
className="w-screen h-screen bg-blend-hard-light fixed top-0 left-0 opacity-25"
alt=""
/> */}
<QuickTopUI />
<div className="backdrop-blur-lg z-10 isolate overflow-scroll no-scrollbar w-screen max-h-screen h-screen antialiased overflow-x-hidden">
<QuickTopUI />
<QuickTopUILogoPart />
{children}
</div>

View File

@@ -4,13 +4,32 @@ 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 { getUserByUserId, UserProfileDetails } from "@/lib/profile";
import { UserProfileHeader } from "@/components/roblox/UserProfileHeader";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ShieldBanIcon } from "lucide-react";
import Link from "next/link";
import { useFriendsHome } from "@/hooks/roblox/useFriends";
import { FriendCarousel } from "@/components/roblox/FriendCarousel";
interface UserProfileContentProps {
userId: string;
}
function ProfileMoreDetails({ profile }: { profile: UserProfileDetails }) {
const theirFriends = useFriendsHome(profile.id.toString());
return (
<>
{!theirFriends && <Skeleton className="w-full h-64" />}
{/*
//@ts-expect-error */}
<FriendCarousel title={<span className="pl-4">Friends</span>} className="overflow-visible -ml-4" friends={theirFriends || []} />
</>
);
}
export default function UserProfileContent({
userId
}: UserProfileContentProps) {
@@ -34,9 +53,34 @@ export default function UserProfileContent({
<div className="p-4 space-y-6">
<UserProfileHeader user={profile} />
<Separator />
<div className="break-all whitespace-normal">
<div className="break-all pl-4 whitespace-pre-line">
{profile.description}
</div>
{profile.isBanned && (
<>
<div className="justify-center w-full pt-6">
<Alert
variant="default"
className="bg-base/50 space-x-2"
>
<ShieldBanIcon />
<AlertTitle>This user is banned</AlertTitle>
<AlertDescription>
Their Roblox account appears to be terminated
from the platform. You can see their inventory
and RAP history on{" "}
<Link
className="text-blue decoration-blue underline"
href={`https://www.rolimons.com/player/${profile.id}`}
>
Rolimons
</Link>
</AlertDescription>
</Alert>
</div>
</>
)}
{!profile.isBanned && <ProfileMoreDetails profile={profile} />}
</div>
);
}

View File

@@ -15,12 +15,15 @@ export function ReactQueryProvider({ children }: Props) {
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1
retry: true
}
}
});
// will cause bun to SEGFAULT
useEffect(() => {
if (!window) return;
// Persist to localStorage (safe, runs client-side)
const localStoragePersister = createAsyncStoragePersister({
storage: window.localStorage

View File

@@ -1,6 +1,6 @@
import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount";
import { useFriendsPresence } from "@/hooks/roblox/usePresence";
import React, { useMemo } from "react";
import React, { ReactNode, useMemo } from "react";
import LazyLoadedImage from "../util/LazyLoadedImage";
import { StupidHoverThing } from "../util/MiscStuff";
import { VerifiedIcon } from "./RobloxIcons";
@@ -15,7 +15,7 @@ export function FriendCarousel({
React.HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
title: string;
title: Element | string;
dontSortByActivity?: boolean;
friends:
| {

View File

@@ -10,6 +10,7 @@ import {
} from "../ui/context-menu";
import { ContextMenuItem } from "@radix-ui/react-context-menu";
import React from "react";
import Link from "next/link";
interface GameCardProps {
game: ContentMetadata;
@@ -71,9 +72,9 @@ export const GameCard = React.memo(function GameCard({ game }: GameCardProps) {
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>
<a href={`https://roblox.com/games/${game.rootPlaceId}`}>
Open URL
</a>
<Link href={`/games/${game.rootPlaceId}`}>
Open
</Link>
</ContextMenuItem>
<ContextMenuItem
onClick={() => {

View File

@@ -1,5 +1,6 @@
import { PremiumIconSmall, VerifiedIcon } from "./RobloxIcons";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { ShieldBanIcon } from "lucide-react";
export function RobloxPremiumSmall(props: React.SVGProps<SVGSVGElement>) {
return (
@@ -28,3 +29,18 @@ export function RobloxVerifiedSmall(
</Tooltip>
);
}
export function RobloxBannedSmall(
props: React.SVGProps<SVGSVGElement> & { useDefault?: boolean }
) {
return (
<Tooltip>
<TooltipTrigger asChild>
<ShieldBanIcon {...props} />
</TooltipTrigger>
<TooltipContent className="bg-surface0 text-text m-2">
<p className="text-sm">Banned from Roblox</p>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -5,6 +5,7 @@ import LazyLoadedImage from "../util/LazyLoadedImage";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { OctagonXIcon } from "lucide-react";
import {
RobloxBannedSmall,
RobloxPremiumSmall,
RobloxVerifiedSmall
} from "@/components/roblox/RobloxTooltips";
@@ -90,13 +91,14 @@ export function UserProfileHeader({ user }: { user: UserProfileDetails }) {
<Skeleton className="w-96 h-8 rounded-lg" />
</>
)}
{isLoaded && user.hasVerifiedBadge ? (
{isLoaded && user.hasVerifiedBadge && (
<RobloxVerifiedSmall className="w-6 h-6 fill-blue text-base" />
) : (
<></>
)}
{isLoaded && user.isBanned && (
<RobloxBannedSmall className="w-6 h-6 text-blue" />
)}
</span>
<span className="text-base font-mono text-subtext0 mt-1">
<span className="text-base font-super-mono text-subtext0 mt-1">
{isLoaded ? (
<>
@{user.name}

View File

@@ -116,7 +116,7 @@ export function HomeLoggedInHeader() {
<></>
)}
</span>
<span className="text-base font-mono text-subtext0 mt-1">
<span className="text-base font-super-mono text-subtext0 mt-1">
{isLoaded ? (
<>
@{profile.name}

View File

@@ -7,6 +7,7 @@ import LazyLoadedImage from "../util/LazyLoadedImage";
import { StupidHoverThing } from "../util/MiscStuff";
import { loadThumbnails } from "@/lib/thumbnailLoader";
import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount";
import { useEffect } from "react";
type OutfitSelectorProps = {
setVisible: (visible: boolean) => void;
@@ -23,6 +24,18 @@ export function OutfitSelector({
const outfits = useAvatarOutfits();
const acc = useCurrentAccount();
useEffect(() => {
if (!outfits) return;
loadThumbnails(
outfits.map((a) => ({
type: "Outfit",
targetId: a.id,
format: "webp",
size: "420x420"
}))
).catch(() => {});
}, [acc, outfits]);
if (!outfits || !acc) return null;
return (
@@ -35,22 +48,22 @@ export function OutfitSelector({
/>
<div className="z-20 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 p-8 bg-crust/90 rounded-xl">
{(outfits || []).map((outfit: { id: number; name: string }) => (
<button
key={outfit.id}
className="hover:bg-base/50 rounded-lg"
onClick={async () => {
updateOutfit(outfit, acc);
setVisible(false);
}}
>
<StupidHoverThing delayDuration={0} text={outfit.name}>
<StupidHoverThing key={outfit.id} delayDuration={0} text={outfit.name}>
<button
key={outfit.id}
className="hover:bg-base/50 rounded-lg"
onClick={async () => {
updateOutfit(outfit, acc);
setVisible(false);
}}
>
<LazyLoadedImage
imgId={`Outfit_${outfit.id}`}
alt={outfit.name}
className="w-32 h-32 rounded-md"
/>
</StupidHoverThing>
</button>
</button>
</StupidHoverThing>
))}
</div>
</div>

View File

@@ -112,7 +112,7 @@ export const QuickTopUI = React.memo(function () {
) : (
<></>
)}
<div className="z-50 absolute top-4 right-4 p-4 flex gap-2 items-center text-blue/75">
<div className="z-50 fixed top-4 right-4 p-4 flex gap-2 items-center text-blue/75">
<StupidHoverThing text="Change Outfit">
<button
className="rounded-full bg-crust/50 flex items-center p-2"

View File

@@ -23,7 +23,7 @@ const LazyLoadedImage: React.FC<LazyLoadedImageProps> = ({
height = 1024,
className,
lazyFetch = true,
size = "48x48",
size = "720x720",
...props
}) => {
const [isVisible, setIsVisible] = useState(!lazyFetch);

View File

@@ -54,7 +54,7 @@ export function useAccountSettings() {
}
},
enabled: !!acct,
staleTime: Infinity,
staleTime: 900_000,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false

View File

@@ -19,25 +19,16 @@ export function useAvatarOutfits(): Outfit[] | false | null {
queryKey: ["avatarOutfits", acct ? acct.id : "acctId"],
enabled: !!acct,
queryFn: async () => {
if (!acct) return false;
if (!acct) return [];
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,
staleTime: 60_000,
refetchOnWindowFocus: false
});

View File

@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { proxyFetch } from "@/lib/utils";
import { loadThumbnails } from "@/lib/thumbnailLoader";
import { useCurrentAccount } from "./useCurrentAccount";
import assert from "assert";
export function useBestFriends():
| {
@@ -46,6 +47,7 @@ export function useBestFriends():
})
}
);
assert(friendsAPICall2.ok);
const J2 = (await friendsAPICall2.json()) as {
data: {

View File

@@ -4,17 +4,19 @@ import { useQuery } from "@tanstack/react-query";
import { useCurrentAccount } from "./useCurrentAccount";
import { proxyFetch } from "@/lib/utils";
import { loadThumbnails } from "@/lib/thumbnailLoader";
import { UserProfileDetails } from "@/lib/profile";
import assert from "assert";
export function useFriendsHome() {
export function useFriendsHome( targetId?: string ) {
const acct = useCurrentAccount();
const target = targetId || (acct ? acct.id : "acctId")
const { data: friends } = useQuery({
queryKey: ["friends", acct ? acct.id : "acctId"],
queryKey: ["friends", target],
queryFn: async () => {
if (!acct) return null;
if (target === "acctId") return [];
const friendsAPICall = await proxyFetch(
`https://friends.roblox.com/v1/users/${acct.id}/friends`
`https://friends.roblox.com/v1/users/${target}/friends`
);
assert(friendsAPICall.ok);
const j = (await friendsAPICall.json()) as {
data: { id: number }[];
};
@@ -24,10 +26,11 @@ export function useFriendsHome() {
method: "POST",
body: JSON.stringify({
userIds: j.data.map((a) => a.id),
excludeBannedUsers: false
excludeBannedUsers: true
})
}
);
assert(friendsAPICall2.ok);
const j2 = (await friendsAPICall2.json()) as {
data: {
hasVerifiedBadge: boolean;
@@ -46,13 +49,13 @@ export function useFriendsHome() {
).catch(() => {});
const friendsList = j.data.map((a) => {
const x = j2.data.find((b) => b.id === a.id);
return {
return !!x ? {
id: a.id,
hasVerifiedBadge: x?.hasVerifiedBadge || false,
name: x?.name || "?",
displayName: x?.displayName || "?"
};
});
} : null;
}).filter(a=>!!a).filter(a=>a.id.toString()!=="-1");
return friendsList;
},
enabled: !!acct,

View File

@@ -0,0 +1,83 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { proxyFetch } from "@/lib/utils";
type Creator = {
id: number;
name: string;
type: string;
/** only used for 罗布乐思 (chinese roblox) */
isRNVAccount: boolean;
hasVerifiedBadge: boolean;
};
type PlaceDetails = {
id: number;
rootPlaceId: number;
name: string;
description: string;
creator: Creator;
playing: number;
visits: number;
maxPlayers: number;
created: string;
updated: string;
genre: string;
genre_l1?: string;
genre_l2?: string;
favoritedCount: number;
isFavoritedByUser: boolean;
universeAvatarType: string;
};
export function usePlaceDetails(placeId: number | string | null) {
const { data: placeDetails } = useQuery<PlaceDetails | null | false>({
queryKey: ["place-details", placeId],
queryFn: async () => {
if (!placeId) return null;
try {
// First: get universeId from place
const res1 = await proxyFetch(
`https://apis.roblox.com/universes/v1/places/${placeId}/universe`
);
if (!res1.ok) {
console.error(
`[usePlaceDetails] API Error ${res1.status} ${res1.statusText}`
);
return false;
}
const { universeId } = await res1.json();
if (!universeId) return false;
// Then: get universe details
const res2 = await proxyFetch(
`https://games.roblox.com/v1/games?universeIds=${universeId}`
);
if (!res2.ok) {
console.error(
`[usePlaceDetails] API Error ${res2.status} ${res2.statusText}`
);
return false;
}
const data = await res2.json();
if (!data?.data?.[0]) return false;
return data.data[0] as PlaceDetails;
} catch (err) {
console.error(
"[usePlaceDetails] Failed to fetch place details",
err
);
return false;
}
},
enabled: !!placeId,
staleTime: 300_000, // cache 5 minutes
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false
});
return placeDetails;
}

View File

@@ -3,6 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import { useCurrentAccount } from "./useCurrentAccount";
import { proxyFetch } from "@/lib/utils";
import assert from "assert";
type PresenceData = {
userPresenceType: number;
@@ -47,14 +48,13 @@ export function useFriendsPresence(userIds: number[]) {
}
);
// assert is shit
if (!res.ok) {
console.error(
`[usePresence] API Error ${res.status} ${res.statusText}`
);
throw new Error(`API request failed with status ${res.status}`);
}
throw "wtf?";
};
const json = await res.json();
return (json.userPresences || []) as PresenceData[];
},
enabled: !!acct && sortedUserIds.length > 0,

View File

@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCurrentAccount } from "./useCurrentAccount";
import { proxyFetch } from "@/lib/utils";
import { useEffect } from "react";
import assert from "assert";
export function useRobuxBalance() {
const acct = useCurrentAccount();
@@ -17,6 +18,7 @@ export function useRobuxBalance() {
const res = await proxyFetch(
`https://economy.roblox.com/v1/users/${acct.id}/currency`
);
assert(res.ok);
const data = await res.json();
return data.robux || 0;
} catch {

View File

@@ -1,5 +1,6 @@
"use client";
import assert from "assert";
import { proxyFetch } from "./utils";
export type UserProfileDetails = {
@@ -27,6 +28,7 @@ export async function getLoggedInUser(): Promise<{
}
}
);
assert(data.ok);
const J = await data.json();
if (J.errors) {
return null;