diff --git a/app/api/discovery/omni-recommendation/route.ts b/app/api/discovery/omni-recommendation/route.ts deleted file mode 100644 index ef15d14..0000000 --- a/app/api/discovery/omni-recommendation/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server" - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const apiUrl = "https://apis.roblox.com/discovery-api/omni-recommendation"; - - const url = `${apiUrl}?${searchParams.toString()}`; - - try { - const response = await fetch(url, { - headers: { - "User-Agent": "OCbwoy3ChanAI/1.0", - "Accept": "application/json, text/plain, */*", - "Accept-Encoding": "gzip, deflate, br, zstd", - "Content-Type": "application/json;charset=UTF-8", - "Cookie": request.headers.get("Authorization") || "", - }, - }); - - return NextResponse.json(await response.json()); - } catch (error) { - console.error("Error proxying request:", error); - return NextResponse.json({ error: "Internal Server Error" }); - } -} - -export async function POST(request: NextRequest) { - const { searchParams } = new URL(request.url); - const apiUrl = "https://apis.roblox.com/discovery-api/omni-recommendation"; - const url = `${apiUrl}?${searchParams.toString()}`; - - try { - const body = await request.json() - const response = await fetch(url, { - method: "POST", - headers: { - "User-Agent": "OCbwoy3ChanAI/1.0", - "Accept": "application/json, text/plain, */*", - "Accept-Encoding": "gzip, deflate, br, zstd", - "Content-Type": "application/json;charset=UTF-8", - "Cookie": request.headers.get("Authorization") || "", - }, - body: JSON.stringify(body), - }); - - return NextResponse.json(await response.json()); - } catch (error) { - console.error("Error proxying request:", error); - return NextResponse.json({ error: "Internal Server Error" }); - } -} - diff --git a/app/api/proxy/route.ts b/app/api/proxy/route.ts new file mode 100644 index 0000000..fa01fb1 --- /dev/null +++ b/app/api/proxy/route.ts @@ -0,0 +1,64 @@ +async function proxyRequest(request: Request, method: string) { + const { searchParams } = new URL(request.url); + const target = searchParams.get("url"); + + if (!target) { + return new Response( + JSON.stringify({ error: "Missing `url` query parameter." }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + } + ); + } + + const targetUrl = new URL(target); + + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("accept-encoding"); // ! important + + const init: RequestInit = { + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : request.body, + }; + + if (init.body !== undefined) { + (init as any).duplex = "half"; + } + + const response = await fetch(targetUrl, init); + + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-encoding"); // ! important + + return new Response(response.body, { + status: response.status, + headers: responseHeaders, + }); +} + +export async function GET(request: Request) { + return proxyRequest(request, "GET"); +} + +export async function HEAD(request: Request) { + return proxyRequest(request, "HEAD"); +} + +export async function POST(request: Request) { + return proxyRequest(request, "POST"); +} + +export async function PUT(request: Request) { + return proxyRequest(request, "PUT"); +} + +export async function DELETE(request: Request) { + return proxyRequest(request, "DELETE"); +} + +export async function PATCH(request: Request) { + return proxyRequest(request, "PATCH"); +} diff --git a/app/api/thumbnails/batch/route.ts b/app/api/thumbnails/batch/route.ts deleted file mode 100644 index 2d0a12c..0000000 --- a/app/api/thumbnails/batch/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server" - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const apiUrl = "https://thumbnails.roblox.com/v1/batch"; - - const url = `${apiUrl}?${searchParams.toString()}`; - - try { - const response = await fetch(url, { - headers: { - "User-Agent": "OCbwoy3ChanAI/1.0", - "Accept": "application/json, text/plain, */*", - "Accept-Encoding": "gzip, deflate, br, zstd", - "Content-Type": "application/json;charset=UTF-8", - "Cookie": request.headers.get("Authorization") || "", - }, - }); - - return NextResponse.json(await response.json()); - } catch (error) { - console.error("Error proxying request:", error); - return NextResponse.json({ error: "Internal Server Error" }); - } -} - -export async function POST(request: NextRequest) { - const { searchParams } = new URL(request.url); - const apiUrl = "https://thumbnails.roblox.com/v1/batch"; - const url = `${apiUrl}?${searchParams.toString()}`; - - try { - const body = await request.json() - const response = await fetch(url, { - method: "POST", - headers: { - "User-Agent": "OCbwoy3ChanAI/1.0", - "Accept": "application/json, text/plain, */*", - "Accept-Encoding": "gzip, deflate, br, zstd", - "Content-Type": "application/json;charset=UTF-8", - "Cookie": request.headers.get("Authorization") || "", - }, - body: JSON.stringify(body), - }); - - return NextResponse.json(await response.json()); - } catch (error) { - console.error("Error proxying request:", error); - return NextResponse.json({ error: "Internal Server Error" }); - } -} - diff --git a/app/api/user/authenticated/route.ts b/app/api/user/authenticated/route.ts deleted file mode 100644 index cf7102c..0000000 --- a/app/api/user/authenticated/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server" - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const apiUrl = "https://users.roblox.com/v1/users/authenticated"; - - const url = `${apiUrl}?${searchParams.toString()}`; - - try { - const response = await fetch(url, { - headers: { - "User-Agent": "OCbwoy3ChanAI/1.0", - "Accept": "application/json, text/plain, */*", - "Accept-Encoding": "gzip, deflate, br, zstd", - "Content-Type": "application/json;charset=UTF-8", - "Cookie": request.headers.get("Authorization") || "", - }, - }); - - return NextResponse.json(await response.json()); - } catch (error) { - console.error("Error proxying request:", error); - return NextResponse.json({ error: "Internal Server Error" }); - } -} diff --git a/app/api/user/route.ts b/app/api/user/route.ts deleted file mode 100644 index 6b18954..0000000 --- a/app/api/user/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -export async function GET(request: NextRequest) { - const u = new URL(request.url); - const apiUrl = `https://users.roblox.com/v1/users/${u.searchParams.get("id")}`; - - try { - const response = await fetch(apiUrl, { - headers: { - "User-Agent": "OCbwoy3ChanAI/1.0", - "Accept": "application/json, text/plain, */*", - "Accept-Encoding": "gzip, deflate, br, zstd", - "Content-Type": "application/json;charset=UTF-8", - "Cookie": request.headers.get("Authorization") || "", - }, - }); - - return NextResponse.json(await response.json()); - } catch (error) { - console.error("Error proxying request:", error); - return NextResponse.json({ error: "Internal Server Error" }); - } -} diff --git a/app/page.tsx b/app/page.tsx index 273e2aa..92db65f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,35 +2,29 @@ import { GameCard } from "@/components/gameCard"; import { HomeLoggedInHeader } from "@/components/loggedInHeader"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { getOmniRecommendationsHome, OmniRecommendation, } from "@/lib/omniRecommendation"; -import { getCookie } from "@/lib/roblox"; -import { loadThumbnails, ThumbnailRequest } from "@/lib/thumbnailLoader"; +import { loadThumbnails } from "@/lib/thumbnailLoader"; import { useEffect, useState } from "react"; export default function Home() { - const [rec, setRec] = useState(null); const SORTS_ALLOWED_IDS = [100000003, 100000001]; + const [rec, setRec] = useState(null); useEffect(() => { (async () => { const r = await getOmniRecommendationsHome(); - console.log("[ROBLOX]", "got omni recommendation from api", r); - let th: ThumbnailRequest[] = []; - r.sorts.filter(a=>SORTS_ALLOWED_IDS.includes(a.topicId)).forEach(b=>{ - (b.recommendationList || []).forEach(c=>{ - th.push({ - type: "GameThumbnail", - targetId: r.contentMetadata.Game[c.contentId.toString()].rootPlaceId, - format: "webp", - size: "384x216" - }) - }) - }); setRec(r); - loadThumbnails(th).catch(a=>console.error(a)) + loadThumbnails( + Object.entries(r.contentMetadata.Game).map((a) => ({ + type: "GameThumbnail", + targetId: Number(a[1].rootPlaceId), + format: "webp", + size: "384x216", + })) + ).catch((a) => {}); })(); }, []); @@ -41,7 +35,7 @@ export default function Home() {
- Loading... + {"Loading..."}
@@ -52,15 +46,13 @@ export default function Home() { return (
- - {"roblox in nextjs"}
- {"require(\"@/lib/roblox\").getCookie().length = "}{getCookie().length}{";"} +
{rec.sorts .filter((a) => SORTS_ALLOWED_IDS.includes(a.topicId)) .map((sort, idx) => (
-

{sort.topic}

+

{sort.topic}

{(sort.recommendationList || []).map( (recommendation, idxb) => { @@ -68,7 +60,9 @@ export default function Home() { rec.contentMetadata.Game[ recommendation.contentId.toString() ]; - return ; + return ( + + ); } )}
diff --git a/components/RobloxIcons.tsx b/components/RobloxIcons.tsx new file mode 100644 index 0000000..c7b2713 --- /dev/null +++ b/components/RobloxIcons.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +const PremiumIcon = (props: React.SVGProps) => ( + + + + + + + premium_small + + + + + + +); + +export default PremiumIcon; \ No newline at end of file diff --git a/components/gameCard.tsx b/components/gameCard.tsx index a0b9b8c..8078696 100644 --- a/components/gameCard.tsx +++ b/components/gameCard.tsx @@ -1,14 +1,16 @@ -import { useThumbnailLazyLoad } from "@/hooks/use-lazy-load"; +"use client"; + import { ContentMetadata } from "@/lib/omniRecommendation"; import LazyLoadedImage from "./lazyLoadedImage"; import { ContextMenu, ContextMenuContent, ContextMenuSeparator, ContextMenuTrigger } from "./ui/context-menu"; import { ContextMenuItem } from "@radix-ui/react-context-menu"; +import React from "react"; interface GameCardProps { game: ContentMetadata; } -export function GameCard({ game }: GameCardProps) { +export const GameCard = React.memo(function GameCard({ game }: GameCardProps) { return ( @@ -22,43 +24,37 @@ export function GameCard({ game }: GameCardProps) { /> ) : (
- No image + {":("}
)}
-
+
{game.playerCount.toLocaleString()}
- + {game.name} - {Math.round((game.totalUpVotes/(game.totalUpVotes+game.totalDownVotes))*100)}% rating - {game.playerCount} players + {Math.round((game.totalUpVotes/(game.totalUpVotes+game.totalDownVotes))*100)}% rating - {game.playerCount.toLocaleString()} playing - {window.location.href = (`https://roblox.com/games/${game.rootPlaceId}`)}}> - Open + + Open URL {window.location.href = (`roblox://placeId=${game.rootPlaceId}`)}}> Play - {navigator.clipboard.writeText(`https://roblox.com/games/${game.rootPlaceId}`)}}> - copy game url - {navigator.clipboard.writeText(`${game.rootPlaceId}`)}}> - copy game id + Copy rootPlaceId {navigator.clipboard.writeText(`${game.universeId}`)}}> - copy universe id - - {navigator.clipboard.writeText(`roblox://placeId=${game.rootPlaceId}`)}}> - copy roblox:// uri + Copy universeId ); -} +}); diff --git a/components/lazyLoadedImage.tsx b/components/lazyLoadedImage.tsx index 03b6b0a..37a6469 100644 --- a/components/lazyLoadedImage.tsx +++ b/components/lazyLoadedImage.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { useThumbnailLazyLoad } from '@/hooks/use-lazy-load'; +import { useThumbnailURL } from '@/hooks/use-lazy-load'; +import { Skeleton } from './ui/skeleton'; interface LazyLoadedImageProps { imgId: string; @@ -7,15 +8,19 @@ interface LazyLoadedImageProps { [prop: string]: string } -const LazyLoadedImage: React.FC = ({ imgId, alt, ...props }: LazyLoadedImageProps) => { - const imgUrl = useThumbnailLazyLoad(imgId); +const LazyLoadedImage: React.FC & LazyLoadedImageProps> = ({ + imgId, + alt, + ...props +}) => { + const imgUrl = useThumbnailURL(imgId); return (
{imgUrl ? ( {alt} ) : ( -

Loading...

+ )}
); diff --git a/components/loggedInHeader.tsx b/components/loggedInHeader.tsx index 60be100..db65ba6 100644 --- a/components/loggedInHeader.tsx +++ b/components/loggedInHeader.tsx @@ -1,11 +1,16 @@ +"use client"; + import { getLoggedInUser, getUserByUserId, UserProfileDetails, } from "@/lib/profile"; -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; +import LazyLoadedImage from "./lazyLoadedImage"; +import { loadThumbnails } from "@/lib/thumbnailLoader"; +import PremiumIcon from "./RobloxIcons"; -export function HomeLoggedInHeader() { +export const HomeLoggedInHeader = React.memo(function HomeLoggedInHeader() { const [profileDetails, setProfileDetails] = useState(null); @@ -17,15 +22,34 @@ export function HomeLoggedInHeader() { }, []); if (!profileDetails) { - return (<>) + return <>; } + loadThumbnails([ + { + type: "AvatarHeadShot", + targetId: profileDetails.id, + format: "webp", + size: "720x720", + }, + ]).catch(a => {}); + return ( -
- Hello, {profileDetails.displayName} - {"@"}{profileDetails.name} -
- {profileDetails.id} +
+ +
+ + Hello, {profileDetails.displayName} + + + + @{profileDetails.name} + +
); -} +}); diff --git a/hooks/use-lazy-load.ts b/hooks/use-lazy-load.ts index a78320b..45ec336 100644 --- a/hooks/use-lazy-load.ts +++ b/hooks/use-lazy-load.ts @@ -1,24 +1,32 @@ import { useState, useEffect } from 'react'; -let gameImages: { [id: string]: string } = {}; +// Shared cache and listeners +const gameImages: { [id: string]: string } = {}; +const listeners: { [id: string]: Set<(url: string) => void> } = {}; -export function useThumbnailLazyLoad(img: string) { - const [status, setStatus] = useState(undefined); +export function useThumbnailURL(img: string) { + const [status, setStatus] = useState(gameImages[img]); useEffect(() => { - const interval = setInterval(() => { - if (gameImages[img]) { - setStatus(gameImages[img]); - clearInterval(interval); - } - }, 100); + if (!listeners[img]) listeners[img] = new Set(); + const update = (url: string) => setStatus(url); + listeners[img].add(update); - return () => clearInterval(interval); - }, []); + // If the image is already available, set it immediately + if (gameImages[img]) setStatus(gameImages[img]); + + return () => { + listeners[img].delete(update); + if (listeners[img].size === 0) delete listeners[img]; + }; + }, [img]); return status; } export function addThumbnail(id: string, url: string) { gameImages[id] = url; + if (listeners[id]) { + listeners[id].forEach(cb => cb(url)); + } } \ No newline at end of file diff --git a/lib/omniRecommendation.ts b/lib/omniRecommendation.ts index 93f13c1..4ec0a91 100644 --- a/lib/omniRecommendation.ts +++ b/lib/omniRecommendation.ts @@ -1,6 +1,7 @@ "use client"; import { getCookie } from "./roblox"; +import { proxyFetch } from "./utils"; type RecommendationEntry = { contentType: "Game"|string, @@ -59,11 +60,8 @@ export type OmniRecommendation = { } export async function getOmniRecommendationsHome(): Promise { - const data = await fetch(`${document.baseURI}api/discovery/omni-recommendation`,{ + const data = await proxyFetch(`https://apis.roblox.com/discovery-api/omni-recommendation`,{ method: "POST", - headers: { - Authorization: `${getCookie()}` - }, body: JSON.stringify({ pageType: "Home", sessionId: crypto.randomUUID(), @@ -72,7 +70,10 @@ export async function getOmniRecommendationsHome(): Promise cpuCores: 4, maxResolution: "1920x1080", maxMemory: 8192 - }) + }), + headers: { + "Content-Type": "application/json" + } }) return await data.json() as OmniRecommendation } diff --git a/lib/profile.ts b/lib/profile.ts index d6d3b13..2236b70 100644 --- a/lib/profile.ts +++ b/lib/profile.ts @@ -1,4 +1,5 @@ import { getCookie } from "./roblox" +import { proxyFetch } from "./utils" export type UserProfileDetails = { description: string, @@ -16,11 +17,11 @@ export async function getLoggedInUser(): Promise<{ name: string, displayName: string }> { - const data = await fetch(`${document.baseURI}api/user/authenticated`, { + const data = await proxyFetch(`https://users.roblox.com/v1/users/authenticated`, { method: "GET", headers: { - Authorization: `${getCookie()}` - }, + "Content-Type": "application/json" + } }) return (await data.json() as any) as { id: number, @@ -30,11 +31,11 @@ export async function getLoggedInUser(): Promise<{ } export async function getUserByUserId(userid: string): Promise { - const data = await fetch(`${document.baseURI}api/user?id=${userid}`, { + const data = await proxyFetch(`https://users.roblox.com/v1/users/${userid}`, { method: "GET", headers: { - Authorization: `${getCookie()}` - }, + "Content-Type": "application/json" + } }) return (await data.json() as any) as UserProfileDetails } \ No newline at end of file diff --git a/lib/roblox.ts b/lib/roblox.ts index db685ed..15524c3 100644 --- a/lib/roblox.ts +++ b/lib/roblox.ts @@ -1,9 +1,5 @@ -"use client"; +export function setCookie(cookie: string): void {} -export function setCookie(cookie: string): void { - window.localStorage.roblosecurity = cookie; -} - -export function getCookie() { - return window.localStorage.roblosecurity +export async function getCookie(): Promise { + return ""; } diff --git a/lib/thumbnailLoader.ts b/lib/thumbnailLoader.ts index c7dc3b1..79599ea 100644 --- a/lib/thumbnailLoader.ts +++ b/lib/thumbnailLoader.ts @@ -1,54 +1,67 @@ "use client"; import { addThumbnail } from "@/hooks/use-lazy-load"; -import { getCookie } from "./roblox"; +import { proxyFetch } from "./utils"; export type AssetThumbnail = { - requestId: string, - errorCode: number, - errorMessage: string, - targetId: number, - state: "Completed" | string, - imageUrl: string, - version: string -} + requestId: string; + errorCode: number; + errorMessage: string; + targetId: number; + state: "Completed" | string; + imageUrl: string; + version: string; +}; export type ThumbnailRequest = { - type: "GameThumbnail", - targetId: number, - format: "webp", - size: string -} + type: "GameThumbnail" | "AvatarHeadShot"; + targetId: number; + format: "webp"; + size: string; +}; -export async function getThumbnails(b: ThumbnailRequest[]): Promise { - const data = await fetch(`${document.baseURI}api/thumbnails/batch`,{ - method: "POST", - headers: { - Authorization: `${getCookie()}` - }, - body: JSON.stringify( - b.map(a=>{ - return { - requestId: `${a.targetId}::${a.type}:${a.size}:${a.format}:regular`, - type: a.type, - targetId: a.targetId, - token: "", - format: a.format, - size: a.size - } - }) - ) - }) - return (await data.json() as any).data as AssetThumbnail[] +/* +! WARNING: DO NOT USE +*/ +export async function getThumbnails( + b: ThumbnailRequest[] +): Promise { + const batchSize = 50; + const results: AssetThumbnail[] = []; + for (let i = 0; i < b.length; i += batchSize) { + const batch = b.slice(i, i + batchSize); + const data = await proxyFetch( + `https://thumbnails.roblox.com/v1/batch`, + { + method: "POST", + body: JSON.stringify( + batch.map((a) => ({ + requestId: `${a.targetId}::${a.type}:${a.size}:${a.format}:regular`, + type: a.type, + targetId: a.targetId, + token: "", + format: a.format, + size: a.size, + })) + ), + headers: { + "Content-Type": "application/json", + }, + } + ); + 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); + }); + results.push(...(json.data as AssetThumbnail[])); + } + return results; } export async function loadThumbnails(b: ThumbnailRequest[]): Promise { const th = await getThumbnails(b); - th.forEach(a=>{ - // 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) - }) } -// https://apis.roblox.com/discovery-api/omni-recommendation \ No newline at end of file +// https://apis.roblox.com/discovery-api/omni-recommendation diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..0ac931e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,32 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); +} + +export async function proxyFetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + const url = + typeof input === "string" + ? input + : input instanceof Request + ? input.url + : ""; + const proxyUrl = `/api/proxy?url=${encodeURIComponent(url)}`; + + // fix headers + const headers = new Headers(init?.headers || {}); + headers.delete("accept-encoding"); // prevent stupid encoding bug + + const fetchInit: RequestInit = { + ...init, + method: init?.method || "GET", + headers, + body: init?.body, + }; + + return window.fetch(proxyUrl, fetchInit); }