fixxxxxxxx
This commit is contained in:
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { usePlaceDetails } from "@/hooks/roblox/usePlaceDetails";
|
import { usePlaceDetails } from "@/hooks/roblox/usePlaceDetails";
|
||||||
import { RobloxVerifiedSmall } from "@/components/roblox/RobloxTooltips";
|
import { RobloxVerifiedSmall } from "@/components/roblox/RobloxTooltips";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useGameLaunch } from "@/components/providers/GameLaunchProvider";
|
||||||
|
|
||||||
interface GamePageContentProps {
|
interface GamePageContentProps {
|
||||||
placeId: string;
|
placeId: string;
|
||||||
@@ -12,11 +13,12 @@ interface GamePageContentProps {
|
|||||||
|
|
||||||
export default function GamePageContent({ placeId }: GamePageContentProps) {
|
export default function GamePageContent({ placeId }: GamePageContentProps) {
|
||||||
const game = usePlaceDetails(placeId);
|
const game = usePlaceDetails(placeId);
|
||||||
|
const { launchGame } = useGameLaunch();
|
||||||
|
|
||||||
// Set dynamic document title
|
// Set dynamic document title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!!game) {
|
if (!!game) {
|
||||||
document.title = `${game.name} | ocbwoy3-chan's roblox`;
|
document.title = `${game.name} | Roblox`;
|
||||||
}
|
}
|
||||||
}, [game]);
|
}, [game]);
|
||||||
|
|
||||||
@@ -24,7 +26,7 @@ export default function GamePageContent({ placeId }: GamePageContentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<Button onClick={a=>open(`roblox://placeId=${game.rootPlaceId}`)}>
|
<Button onClick={() => launchGame(game.rootPlaceId.toString())}>
|
||||||
PLAY
|
PLAY
|
||||||
</Button>
|
</Button>
|
||||||
<div className="break-all pl-4 whitespace-pre-line font-black text-2xl">
|
<div className="break-all pl-4 whitespace-pre-line font-black text-2xl">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
@tailwind base;
|
@config "../tailwind.config.ts";
|
||||||
@tailwind components;
|
@import "tailwindcss";
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: SF Pro Display, Geist;
|
font-family: SF Pro Display, Geist;
|
||||||
@@ -12,6 +11,20 @@ body {
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
|
--ctp-base: 240 21.052631735801697% 14.901961386203766%; /* base */
|
||||||
|
--ctp-mantle: 240 21.311475336551666% 11.96078434586525%; /* mantle */
|
||||||
|
--ctp-crust: 240 23.404255509376526% 8.627450853586197%; /* crust */
|
||||||
|
--ctp-text: 226 63.93442749977112% 88.03921341896057%; /* text */
|
||||||
|
--ctp-subtext0: 227 23.076922595500946% 71.96078300476074%; /* subtext0 */
|
||||||
|
--ctp-subtext1: 227 35.29411852359772% 80.0000011920929%; /* subtext1 */
|
||||||
|
--ctp-surface0: 237 16.239316761493683% 22.94117659330368%; /* surface0 */
|
||||||
|
--ctp-surface1: 234 13.20754736661911% 31.176471710205078%; /* surface1 */
|
||||||
|
--ctp-surface2: 233 12.05937068939209% 39.607844948768616%; /* surface2 */
|
||||||
|
--ctp-blue: 217 91.86992049217224% 75.88235139846802%; /* blue */
|
||||||
|
--ctp-green: 115 54.09836173057556% 76.07843279838562%; /* green */
|
||||||
|
--ctp-yellow: 41 86.04651093482971% 83.13725590705872%; /* yellow */
|
||||||
|
--ctp-red: 343 81.25% 74.90196228027344%; /* red */
|
||||||
|
|
||||||
--background: 240 21.052631735801697% 14.901961386203766%; /* base */
|
--background: 240 21.052631735801697% 14.901961386203766%; /* base */
|
||||||
--foreground: 226 63.93442749977112% 88.03921341896057%; /* text */
|
--foreground: 226 63.93442749977112% 88.03921341896057%; /* text */
|
||||||
|
|
||||||
@@ -69,6 +82,44 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
--color-card: hsl(var(--card));
|
||||||
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
--color-chart-1: hsl(var(--chart-1));
|
||||||
|
--color-chart-2: hsl(var(--chart-2));
|
||||||
|
--color-chart-3: hsl(var(--chart-3));
|
||||||
|
--color-chart-4: hsl(var(--chart-4));
|
||||||
|
--color-chart-5: hsl(var(--chart-5));
|
||||||
|
--color-sidebar: hsl(var(--sidebar-background));
|
||||||
|
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||||
|
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
||||||
|
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
||||||
|
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
||||||
|
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
||||||
|
--color-sidebar-border: hsl(var(--sidebar-border));
|
||||||
|
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
@@ -88,3 +139,6 @@ body {
|
|||||||
scrollbar-width: 0;
|
scrollbar-width: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@utility border-border {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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";
|
import { ReactQueryProvider } from "@/components/providers/ReactQueryProvider";
|
||||||
|
import { GameLaunchProvider } from "@/components/providers/GameLaunchProvider";
|
||||||
|
import { GameLaunchDialog } from "@/components/providers/GameLaunchDialog";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -18,8 +20,10 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "home | ocbwoy3-chan's roblox",
|
title: "Home | Roblox",
|
||||||
description: "roblox meets next.js i think"
|
description: "Roblox is a global platform that brings people together through play.",
|
||||||
|
authors: [{name: "Roblox Corporation"}],
|
||||||
|
keywords: ["free games", "online games", "building games", "virtual worlds", "free mmo", "gaming cloud", "physics engine"]
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -34,21 +38,24 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<ReactQueryProvider>
|
<ReactQueryProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<main>
|
<GameLaunchProvider>
|
||||||
{/* <Image
|
<main>
|
||||||
src={"/bg.png"}
|
{/* <Image
|
||||||
width={1920}
|
src={"/bg.png"}
|
||||||
height={1080}
|
width={1920}
|
||||||
className="w-screen h-screen bg-blend-hard-light fixed top-0 left-0 opacity-25"
|
height={1080}
|
||||||
alt=""
|
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 />
|
<div className="backdrop-blur-lg z-10 isolate overflow-scroll no-scrollbar w-screen max-h-screen h-screen antialiased overflow-x-hidden">
|
||||||
{children}
|
<QuickTopUILogoPart />
|
||||||
</div>
|
{children}
|
||||||
</main>
|
</div>
|
||||||
<Toaster />
|
</main>
|
||||||
|
<GameLaunchDialog />
|
||||||
|
<Toaster />
|
||||||
|
</GameLaunchProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ReactQueryProvider>
|
</ReactQueryProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function Home() {
|
|||||||
<div className="h-4" />
|
<div className="h-4" />
|
||||||
<BestFriendsHomeSect className="pt-2" />
|
<BestFriendsHomeSect className="pt-2" />
|
||||||
<FriendsHomeSect className="pt-2" />
|
<FriendsHomeSect className="pt-2" />
|
||||||
<div className="justify-center w-screen px-8 pt-6">
|
{/* <div className="justify-center w-screen px-8 pt-6">
|
||||||
<Alert variant="default" className="bg-base/50 space-x-2">
|
<Alert variant="default" className="bg-base/50 space-x-2">
|
||||||
<AlertTriangleIcon />
|
<AlertTriangleIcon />
|
||||||
<AlertTitle>Warning</AlertTitle>
|
<AlertTitle>Warning</AlertTitle>
|
||||||
@@ -64,7 +64,7 @@ export default function Home() {
|
|||||||
process on GitHub.
|
process on GitHub.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
<div className="p-4 space-y-8 no-scrollbar">
|
<div className="p-4 space-y-8 no-scrollbar">
|
||||||
{isLoading || !rec ? (
|
{isLoading || !rec ? (
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function UserProfileContent({
|
|||||||
// Set dynamic document title
|
// Set dynamic document title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile?.displayName) {
|
if (profile?.displayName) {
|
||||||
document.title = `${profile.displayName}'s profile | ocbwoy3-chan's roblox`;
|
document.title = `${profile.displayName}'s profile | Roblox`;
|
||||||
}
|
}
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
|
|||||||
79
components/providers/GameLaunchDialog.tsx
Normal file
79
components/providers/GameLaunchDialog.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
import { closeGameLaunch, getGameLaunchState, subscribeGameLaunch } from "@/components/providers/game-launch-store";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { RobloxLogoIcon } from "@/components/roblox/RobloxIcons";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function GameLaunchDialog() {
|
||||||
|
const state = useSyncExternalStore(
|
||||||
|
subscribeGameLaunch,
|
||||||
|
getGameLaunchState,
|
||||||
|
getGameLaunchState
|
||||||
|
);
|
||||||
|
|
||||||
|
const [launchTimeouted, setLaunchTimeouted] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.isOpen) {
|
||||||
|
setLaunchTimeouted(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setLaunchTimeouted(true);
|
||||||
|
}, 5000); // 5 seconds
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [state.isOpen]);
|
||||||
|
|
||||||
|
if (!state.isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-mantle/70 backdrop-blur-sm"
|
||||||
|
onClick={closeGameLaunch}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-[92vw] max-w-sm rounded-2xl bg-crust/95 ring-1 ring-surface0/60 shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={closeGameLaunch}
|
||||||
|
aria-label="Close launcher"
|
||||||
|
className="absolute right-3 top-3"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col items-center gap-4 px-6 py-8 text-center">
|
||||||
|
<div className="h-24 w-24 flex items-center justify-center">
|
||||||
|
<RobloxLogoIcon />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-2xl font-semibold text-text">
|
||||||
|
{!launchTimeouted ? (
|
||||||
|
<>
|
||||||
|
Roblox is now loading.<br />Get Ready!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Download Roblox to play millions of experiences!</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button disabled={!launchTimeouted} variant="default" className="w-full rounded-full">
|
||||||
|
{launchTimeouted ? (
|
||||||
|
<Link href="https://flathub.org/en/apps/org.vinegarhq.Sober" target="_blank" rel="noopener noreferrer">
|
||||||
|
Download Roblox
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{!launchTimeouted && <div className="h-4 w-4 rounded-full border-2 border-white/70 border-t-transparent animate-spin" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
components/providers/GameLaunchProvider.tsx
Normal file
60
components/providers/GameLaunchProvider.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useCallback, useContext, useMemo } from "react";
|
||||||
|
import { openGameLaunchWithParams } from "@/components/providers/game-launch-store";
|
||||||
|
|
||||||
|
type GameLaunchContextValue = {
|
||||||
|
launchGame: (placeId: string, jobId?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GameLaunchContext = createContext<GameLaunchContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useGameLaunch() {
|
||||||
|
const ctx = useContext(GameLaunchContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useGameLaunch must be used within GameLaunchProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameLaunchProvider({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const launchGame = useCallback((placeId: string, jobId?: string) => {
|
||||||
|
openGameLaunchWithParams(placeId, jobId);
|
||||||
|
|
||||||
|
console.log("[GameLaunchProvider] Launching",{placeId, jobId});
|
||||||
|
|
||||||
|
const gameLaunchParams = {
|
||||||
|
launchmode: "play",
|
||||||
|
LaunchExp: "InApp",
|
||||||
|
placeId: placeId,
|
||||||
|
gameInstanceId: jobId ?? undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("[GameLaunchProvider] Constructed GameLaunchParams",gameLaunchParams);
|
||||||
|
|
||||||
|
const url = new URL("roblox://experiences/start")
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(gameLaunchParams)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
url.searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deeplinkNew = url.toString();
|
||||||
|
console.log("[GameLaunchProvider] Opening URL:", deeplinkNew);
|
||||||
|
|
||||||
|
document.location.href = deeplinkNew;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ launchGame }), [launchGame]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameLaunchContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</GameLaunchContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode, useEffect } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { persistQueryClient } from "@tanstack/react-query-persist-client";
|
import { persistQueryClient } from "@tanstack/react-query-persist-client";
|
||||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||||
@@ -11,19 +11,22 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReactQueryProvider({ children }: Props) {
|
export function ReactQueryProvider({ children }: Props) {
|
||||||
const queryClient = new QueryClient({
|
const [queryClient] = useState(
|
||||||
defaultOptions: {
|
() =>
|
||||||
queries: {
|
new QueryClient({
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
defaultOptions: {
|
||||||
retry: true
|
queries: {
|
||||||
}
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
}
|
retry: true
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// will cause bun to SEGFAULT
|
// will cause bun to SEGFAULT
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window) return;
|
if (typeof window === "undefined") return;
|
||||||
// Persist to localStorage (safe, runs client-side)
|
// Persist to localStorage (safe, runs client-side)
|
||||||
const localStoragePersister = createAsyncStoragePersister({
|
const localStoragePersister = createAsyncStoragePersister({
|
||||||
storage: window.localStorage
|
storage: window.localStorage
|
||||||
@@ -34,7 +37,7 @@ export function ReactQueryProvider({ children }: Props) {
|
|||||||
persister: localStoragePersister,
|
persister: localStoragePersister,
|
||||||
maxAge: 1000 * 60 * 60 // 1 hour max
|
maxAge: 1000 * 60 * 60 // 1 hour max
|
||||||
});
|
});
|
||||||
}, [window || "wtf"]);
|
}, [queryClient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
35
components/providers/game-launch-store.ts
Normal file
35
components/providers/game-launch-store.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
type GameLaunchState = {
|
||||||
|
isOpen: boolean;
|
||||||
|
placeId?: string;
|
||||||
|
gameInstanceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let state: GameLaunchState = { isOpen: false };
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGameLaunchState() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeGameLaunch(listener: () => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openGameLaunchWithParams(placeId: string, jobId?: string) {
|
||||||
|
state = {
|
||||||
|
isOpen: true,
|
||||||
|
placeId,
|
||||||
|
gameInstanceId: jobId
|
||||||
|
};
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeGameLaunch() {
|
||||||
|
state = { isOpen: false };
|
||||||
|
emit();
|
||||||
|
}
|
||||||
@@ -11,12 +11,15 @@ import {
|
|||||||
import { ContextMenuItem } from "@radix-ui/react-context-menu";
|
import { ContextMenuItem } from "@radix-ui/react-context-menu";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useGameLaunch } from "@/components/providers/GameLaunchProvider";
|
||||||
|
|
||||||
interface GameCardProps {
|
interface GameCardProps {
|
||||||
game: ContentMetadata;
|
game: ContentMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GameCard = React.memo(function GameCard({ game }: GameCardProps) {
|
export const GameCard = React.memo(function GameCard({ game }: GameCardProps) {
|
||||||
|
const { launchGame } = useGameLaunch();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger>
|
<ContextMenuTrigger>
|
||||||
@@ -78,7 +81,7 @@ export const GameCard = React.memo(function GameCard({ game }: GameCardProps) {
|
|||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.href = `roblox://placeId=${game.rootPlaceId}`;
|
launchGame(game.rootPlaceId.toString());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Play
|
Play
|
||||||
|
|||||||
@@ -95,3 +95,21 @@ export const RobuxIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const RobloxLogoIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" fill="none" viewBox="0 0 1024 1024">
|
||||||
|
<g clipPath="url(#a)">
|
||||||
|
<mask id="b" width="1024" height="1024" x="0" y="0" maskUnits="userSpaceOnUse" className="mask-type-alpha">
|
||||||
|
<path fill="#d9d9d9" d="M0 365.856c0-128.061 0-192.092 24.923-241.005a228.66 228.66 0 0 1 99.928-99.928C173.764 0 237.795 0 365.856 0h292.288c128.061 0 192.092 0 241.005 24.923a228.66 228.66 0 0 1 99.929 99.928C1024 173.764 1024 237.795 1024 365.856v292.288c0 128.061 0 192.092-24.922 241.005a228.66 228.66 0 0 1-99.929 99.929C850.236 1024 786.205 1024 658.144 1024H365.856c-128.061 0-192.092 0-241.005-24.922a228.66 228.66 0 0 1-99.928-99.929C0 850.236 0 786.205 0 658.144z"/>
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#b)"><path fill="#335fff" d="M0 0h1024v1024H0z"/>
|
||||||
|
<path fill="#fff" d="m307.201 157.281-149.92 559.518 559.518 149.92 149.92-559.518zm262.041 453.876-156.349-41.915 41.914-156.349 156.412 41.914z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="a">
|
||||||
|
<path fill="#fff" d="M0 0h1024v1024H0z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import LazyLoadedImage from "../util/LazyLoadedImage";
|
import LazyLoadedImage from "../util/LazyLoadedImage";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||||
import { OctagonXIcon } from "lucide-react";
|
import { OctagonXIcon } from "lucide-react";
|
||||||
@@ -15,6 +15,7 @@ import { useAccountSettings } from "@/hooks/roblox/useAccountSettings";
|
|||||||
import { loadThumbnails } from "@/lib/thumbnailLoader";
|
import { loadThumbnails } from "@/lib/thumbnailLoader";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
// chatgpt + human
|
// chatgpt + human
|
||||||
function randomGreeting(name: string): string {
|
function randomGreeting(name: string): string {
|
||||||
@@ -27,6 +28,15 @@ function randomGreeting(name: string): string {
|
|||||||
export function HomeLoggedInHeader() {
|
export function HomeLoggedInHeader() {
|
||||||
const profile = useCurrentAccount();
|
const profile = useCurrentAccount();
|
||||||
const accountSettings = useAccountSettings();
|
const accountSettings = useAccountSettings();
|
||||||
|
const [preferredName, setPreferredName] = useState<string | null>(null);
|
||||||
|
const profileId = profile ? profile.id : undefined;
|
||||||
|
const presence = useFriendsPresence(profileId ? [profileId] : []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const storedName = window.localStorage.getItem("UserPreferredName");
|
||||||
|
if (storedName) setPreferredName(storedName);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (profile === false) {
|
if (profile === false) {
|
||||||
return (
|
return (
|
||||||
@@ -43,8 +53,6 @@ export function HomeLoggedInHeader() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const presence = useFriendsPresence(profile ? [profile.id] : []);
|
|
||||||
|
|
||||||
const userActivity = presence.find((b) => b.userId === profile?.id);
|
const userActivity = presence.find((b) => b.userId === profile?.id);
|
||||||
const userPresence = userActivity?.userPresenceType;
|
const userPresence = userActivity?.userPresenceType;
|
||||||
const borderColor =
|
const borderColor =
|
||||||
@@ -94,7 +102,7 @@ export function HomeLoggedInHeader() {
|
|||||||
{isLoaded ? (
|
{isLoaded ? (
|
||||||
<Link href={`/users/${profile.id}`}>
|
<Link href={`/users/${profile.id}`}>
|
||||||
{randomGreeting(
|
{randomGreeting(
|
||||||
window.localStorage.UserPreferredName ||
|
preferredName ||
|
||||||
profile.displayName ||
|
profile.displayName ||
|
||||||
"Robloxian!"
|
"Robloxian!"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useAvatarOutfits } from "@/hooks/roblox/useAvatarOutfits";
|
import { useAvatarOutfits } from "@/hooks/roblox/useAvatarOutfits";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn, proxyFetch } from "@/lib/utils";
|
|
||||||
import LazyLoadedImage from "../util/LazyLoadedImage";
|
import LazyLoadedImage from "../util/LazyLoadedImage";
|
||||||
import { StupidHoverThing } from "../util/MiscStuff";
|
import { StupidHoverThing } from "../util/MiscStuff";
|
||||||
import { loadThumbnails } from "@/lib/thumbnailLoader";
|
import { loadThumbnails } from "@/lib/thumbnailLoader";
|
||||||
import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount";
|
import { useCurrentAccount } from "@/hooks/roblox/useCurrentAccount";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
type OutfitSelectorProps = {
|
type OutfitSelectorProps = {
|
||||||
setVisible: (visible: boolean) => void;
|
setVisible: (visible: boolean) => void;
|
||||||
@@ -25,7 +25,7 @@ export function OutfitSelector({
|
|||||||
const acc = useCurrentAccount();
|
const acc = useCurrentAccount();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!outfits) return;
|
if (!outfits || outfits.length === 0) return;
|
||||||
loadThumbnails(
|
loadThumbnails(
|
||||||
outfits.map((a) => ({
|
outfits.map((a) => ({
|
||||||
type: "Outfit",
|
type: "Outfit",
|
||||||
@@ -34,37 +34,99 @@ export function OutfitSelector({
|
|||||||
size: "420x420"
|
size: "420x420"
|
||||||
}))
|
}))
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}, [acc, outfits]);
|
}, [outfits]);
|
||||||
|
|
||||||
if (!outfits || !acc) return null;
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setVisible(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [setVisible]);
|
||||||
|
|
||||||
|
const isLoading = outfits === null;
|
||||||
|
const hasOutfits = Array.isArray(outfits) && outfits.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="z-30 isolate absolute inset-0 flex items-center justify-center bg-crust/50">
|
<div
|
||||||
<button
|
className="fixed inset-0 z-40 flex items-center justify-center bg-mantle/70 backdrop-blur-sm"
|
||||||
className="z-10 absolute w-screen h-screen cursor-default"
|
onClick={() => setVisible(false)}
|
||||||
onClick={() => {
|
>
|
||||||
setVisible(false);
|
<div
|
||||||
}}
|
className="relative w-full max-w-3xl sm:max-w-4xl mx-4 rounded-2xl bg-crust/95 ring-1 ring-surface0/60 shadow-2xl"
|
||||||
/>
|
onClick={(event) => event.stopPropagation()}
|
||||||
<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 }) => (
|
<div className="flex items-center justify-between border-b border-surface0/60 px-6 py-4">
|
||||||
<StupidHoverThing key={outfit.id} delayDuration={0} text={outfit.name}>
|
<div>
|
||||||
<button
|
<p className="text-lg font-semibold text-text">Outfits</p>
|
||||||
key={outfit.id}
|
<p className="text-xs text-subtext1">
|
||||||
className="hover:bg-base/50 rounded-lg"
|
Pick a look to update your avatar instantly.
|
||||||
onClick={async () => {
|
</p>
|
||||||
updateOutfit(outfit, acc);
|
</div>
|
||||||
setVisible(false);
|
<Button
|
||||||
}}
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<LazyLoadedImage
|
onClick={() => setVisible(false)}
|
||||||
imgId={`Outfit_${outfit.id}`}
|
aria-label="Close outfit chooser"
|
||||||
alt={outfit.name}
|
>
|
||||||
className="w-32 h-32 rounded-md"
|
<X className="h-4 w-4" />
|
||||||
/>
|
</Button>
|
||||||
</button>
|
</div>
|
||||||
</StupidHoverThing>
|
|
||||||
))}
|
<div className="p-6">
|
||||||
|
{!acc ? (
|
||||||
|
<div className="rounded-xl border border-surface0/60 bg-base/50 p-6 text-sm text-subtext1">
|
||||||
|
Sign in to load your outfits.
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={`outfit-skeleton-${index}`}
|
||||||
|
className="rounded-xl border border-surface0/60 bg-base/40 p-3"
|
||||||
|
>
|
||||||
|
<div className="h-24 w-24 sm:h-28 sm:w-28 rounded-lg bg-surface0/70 animate-pulse" />
|
||||||
|
<div className="mt-3 h-3 w-20 rounded bg-surface0/70 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : hasOutfits ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||||
|
{outfits.map((outfit: { id: number; name: string }) => (
|
||||||
|
<StupidHoverThing
|
||||||
|
key={outfit.id}
|
||||||
|
delayDuration={0}
|
||||||
|
text={outfit.name}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="group rounded-xl border border-surface0/50 bg-base/40 p-3 text-left transition hover:-translate-y-0.5 hover:border-surface1/80 hover:bg-surface0/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue/60"
|
||||||
|
onClick={async () => {
|
||||||
|
await updateOutfit(outfit, acc);
|
||||||
|
setVisible(false);
|
||||||
|
}}
|
||||||
|
aria-label={`Wear ${outfit.name}`}
|
||||||
|
>
|
||||||
|
<LazyLoadedImage
|
||||||
|
imgId={`Outfit_${outfit.id}`}
|
||||||
|
alt={outfit.name}
|
||||||
|
className="h-24 w-24 sm:h-28 sm:w-28 rounded-lg object-cover shadow-sm"
|
||||||
|
size="420x420"
|
||||||
|
lazyFetch={false}
|
||||||
|
/>
|
||||||
|
<p className="mt-3 text-xs font-medium text-text line-clamp-2">
|
||||||
|
{outfit.name}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</StupidHoverThing>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-surface0/60 bg-base/50 p-6 text-sm text-subtext1">
|
||||||
|
No outfits found yet. Make one in the Roblox avatar editor,
|
||||||
|
then come back here.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useFriendsPresence } from "@/hooks/roblox/usePresence";
|
|||||||
|
|
||||||
async function updateOutfit(outfit: { id: number }, acc: { id: number }) {
|
async function updateOutfit(outfit: { id: number }, acc: { id: number }) {
|
||||||
try {
|
try {
|
||||||
// ocbwoy3 stupid idiot for using v3 api
|
|
||||||
const details = (await (
|
const details = (await (
|
||||||
await proxyFetch(
|
await proxyFetch(
|
||||||
`https://avatar.roblox.com/v1/outfits/${outfit.id}/details`
|
`https://avatar.roblox.com/v1/outfits/${outfit.id}/details`
|
||||||
@@ -24,18 +23,7 @@ async function updateOutfit(outfit: { id: number }, acc: { id: number }) {
|
|||||||
).json()) as {
|
).json()) as {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
bodyColors: Record<string, string>;
|
assets: Array<{ id: number; meta?: Record<string, unknown> }>;
|
||||||
scale: Record<string, number>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const detailsV3 = (await (
|
|
||||||
await proxyFetch(
|
|
||||||
`https://avatar.roblox.com/v3/outfits/${outfit.id}/details`
|
|
||||||
)
|
|
||||||
).json()) as {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
assets: any[];
|
|
||||||
bodyColors: Record<string, string>;
|
bodyColors: Record<string, string>;
|
||||||
scale: Record<string, number>;
|
scale: Record<string, number>;
|
||||||
playerAvatarType: "R6" | "R15";
|
playerAvatarType: "R6" | "R15";
|
||||||
@@ -58,26 +46,44 @@ async function updateOutfit(outfit: { id: number }, acc: { id: number }) {
|
|||||||
|
|
||||||
// u cant set avatar item scaling/rotation cuz roblox can't make good web apis
|
// u cant set avatar item scaling/rotation cuz roblox can't make good web apis
|
||||||
await proxyFetch(
|
await proxyFetch(
|
||||||
`https://avatar.roblox.com/v1/avatar/set-wearing-assets`,
|
`https://avatar.roblox.com/v2/avatar/set-wearing-assets`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
assetIds: detailsV3.assets.map((a) => a.id).filter(Boolean)
|
assets: details.assets
|
||||||
|
.map((asset) => ({
|
||||||
|
id: asset.id,
|
||||||
|
meta: asset.meta
|
||||||
|
}))
|
||||||
|
.filter((asset) => Boolean(asset.id))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
await proxyFetch(
|
const avatarType =
|
||||||
`https://avatar.roblox.com/v1/avatar/set-player-avatar-type`,
|
details.playerAvatarType === "R15"
|
||||||
{
|
? 3
|
||||||
method: "POST",
|
: details.playerAvatarType === "R6"
|
||||||
headers: { "Content-Type": "application/json" },
|
? 1
|
||||||
body: JSON.stringify({
|
: null;
|
||||||
playerAvatarType: detailsV3.playerAvatarType
|
|
||||||
})
|
if (avatarType !== null) {
|
||||||
}
|
await proxyFetch(
|
||||||
);
|
`https://avatar.roblox.com/v1/avatar/set-player-avatar-type`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
playerAvatarType: avatarType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await proxyFetch(`https://avatar.roblox.com/v1/avatar/redraw-thumbnail`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
|
||||||
loadThumbnails([
|
loadThumbnails([
|
||||||
{
|
{
|
||||||
@@ -112,15 +118,15 @@ export const QuickTopUI = React.memo(function () {
|
|||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
<div className="z-50 fixed top-4 right-4 p-4 flex gap-2 items-center text-blue/75">
|
<div className="z-50 fixed top-4 right-4 flex gap-2 items-center text-text">
|
||||||
<StupidHoverThing text="Change Outfit">
|
<StupidHoverThing text="Change Outfit">
|
||||||
<button
|
<button
|
||||||
className="rounded-full bg-crust/50 flex items-center p-2"
|
className="rounded-full bg-surface0/70 ring-1 ring-surface1/60 flex items-center justify-center h-10 w-10 text-text shadow-sm transition hover:bg-surface1/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue/60"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsOutfitSelectorVisible((a) => !a);
|
setIsOutfitSelectorVisible((a) => !a);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ShirtIcon />
|
<ShirtIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</StupidHoverThing>
|
</StupidHoverThing>
|
||||||
|
|
||||||
@@ -131,15 +137,11 @@ export const QuickTopUI = React.memo(function () {
|
|||||||
: `You have ${robux.toLocaleString()} Robux`
|
: `You have ${robux.toLocaleString()} Robux`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="rounded-full bg-crust/50 flex items-center p-2">
|
<div className="rounded-full bg-surface0/70 ring-1 ring-surface1/60 flex items-center h-10 px-3 gap-2 text-text shadow-sm">
|
||||||
<RobuxIcon className="w-6 h-6" />
|
<RobuxIcon className="w-5 h-5 text-green" />
|
||||||
{robux ? (
|
<p className="text-sm font-super-mono tabular-nums">
|
||||||
<p className="pl-1">
|
{robux ? robux.toLocaleString() : "..."}
|
||||||
{robux ? robux.toLocaleString() : "???"}
|
</p>
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</StupidHoverThing>
|
</StupidHoverThing>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,12 +151,15 @@ export const QuickTopUI = React.memo(function () {
|
|||||||
|
|
||||||
export const QuickTopUILogoPart = React.memo(function () {
|
export const QuickTopUILogoPart = React.memo(function () {
|
||||||
return (
|
return (
|
||||||
<div className="z-[15] relative top-4 left-4 p-4 flex gap-4 items-center text-blue">
|
<div className="z-15 relative top-4 left-4 flex gap-3 items-center rounded-full">
|
||||||
<Link href="/" className="-m-1 w-8 h-8">
|
<Link
|
||||||
<img src="/icon-512.webp" className="w-8 h-8" alt="" />
|
href="/"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<img src="/roblox.png" className="w-6 h-6" alt="" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/" className="mt-2 gap-2 flex items-center">
|
<Link href="/" className="gap-2 flex items-center text-sm font-medium">
|
||||||
<p>{"ocbwoy3-chan's roblox"}</p>
|
<p>{"Roblox"}</p>
|
||||||
{/* <p className="text-surface2 line-clamp-1">
|
{/* <p className="text-surface2 line-clamp-1">
|
||||||
{process.env.NODE_ENV} {process.env.NEXT_PUBLIC_CWD}{" "}
|
{process.env.NODE_ENV} {process.env.NEXT_PUBLIC_CWD}{" "}
|
||||||
{process.env.NEXT_PUBLIC_ARGV0}
|
{process.env.NEXT_PUBLIC_ARGV0}
|
||||||
|
|||||||
@@ -102,16 +102,19 @@ ${colorConfig
|
|||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
type ChartTooltipContentProps =
|
||||||
HTMLDivElement,
|
RechartsPrimitive.TooltipContentProps<number | string, string> &
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean;
|
hideLabel?: boolean;
|
||||||
hideIndicator?: boolean;
|
hideIndicator?: boolean;
|
||||||
indicator?: "line" | "dot" | "dashed";
|
indicator?: "line" | "dot" | "dashed";
|
||||||
nameKey?: string;
|
nameKey?: string;
|
||||||
labelKey?: string;
|
labelKey?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ChartTooltipContentProps
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -181,7 +184,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -288,13 +291,18 @@ ChartTooltipContent.displayName = "ChartTooltip";
|
|||||||
|
|
||||||
const ChartLegend = RechartsPrimitive.Legend;
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
type ChartLegendContentProps = React.ComponentProps<"div"> &
|
||||||
|
Pick<
|
||||||
|
RechartsPrimitive.DefaultLegendContentProps,
|
||||||
|
"payload" | "verticalAlign"
|
||||||
|
> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
ChartLegendContentProps
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
|
||||||
hideIcon?: boolean;
|
|
||||||
nameKey?: string;
|
|
||||||
}
|
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils";
|
|||||||
const ResizablePanelGroup = ({
|
const ResizablePanelGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitive.Group
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -24,12 +24,12 @@ const ResizableHandle = ({
|
|||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||||
withHandle?: boolean;
|
withHandle?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.Separator
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
"relative flex items-center justify-center bg-border focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 aria-[orientation=vertical]:h-full aria-[orientation=vertical]:w-px aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:-translate-y-1/2 aria-[orientation=horizontal]:after:translate-x-0 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
|||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.Separator>
|
||||||
);
|
);
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
|
|||||||
@@ -12,21 +12,25 @@ export function useCurrentAccount(): UserProfileDetails | null | false {
|
|||||||
const query = useQuery<UserProfileDetails | false>({
|
const query = useQuery<UserProfileDetails | false>({
|
||||||
queryKey: ["currentAccount"],
|
queryKey: ["currentAccount"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const authed = await getLoggedInUser();
|
try {
|
||||||
if (!authed) return false;
|
const authed = await getLoggedInUser();
|
||||||
|
if (!authed) return false;
|
||||||
|
|
||||||
const user = await getUserByUserId(authed.id.toString());
|
const user = await getUserByUserId(authed.id.toString());
|
||||||
|
|
||||||
loadThumbnails([
|
loadThumbnails([
|
||||||
{
|
{
|
||||||
type: "AvatarHeadShot",
|
type: "AvatarHeadShot",
|
||||||
targetId: authed.id,
|
targetId: authed.id,
|
||||||
format: "webp",
|
format: "webp",
|
||||||
size: "720x720"
|
size: "720x720"
|
||||||
}
|
}
|
||||||
]).catch(() => {});
|
]).catch(() => {});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
staleTime: 1000 * 60 * 5,
|
staleTime: 1000 * 60 * 5,
|
||||||
refetchOnWindowFocus: false
|
refetchOnWindowFocus: false
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import assert from "assert";
|
|
||||||
import { proxyFetch } from "./utils";
|
import { proxyFetch } from "./utils";
|
||||||
|
|
||||||
export type UserProfileDetails = {
|
export type UserProfileDetails = {
|
||||||
@@ -28,7 +27,9 @@ export async function getLoggedInUser(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert(data.ok);
|
if (!data.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const J = await data.json();
|
const J = await data.json();
|
||||||
if (J.errors) {
|
if (J.errors) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
114
package.json
114
package.json
@@ -3,76 +3,76 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "bunx --bun next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "bunx --bun next build",
|
||||||
"start": "next start",
|
"start": "bunx --bun next start",
|
||||||
"lint": "next lint"
|
"lint": "bunx --bun next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/tailwindcss": "^0.1.6",
|
"@catppuccin/tailwindcss": "^1.0.0",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@ocbwoy3/libocbwoy3": "^0.0.5",
|
"@ocbwoy3/libocbwoy3": "^0.0.6",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.15",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.14",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-menubar": "^1.1.15",
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.5",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-toggle": "^1.1.9",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/line-clamp": "^0.4.4",
|
"@tanstack/query-async-storage-persister": "^5.90.14",
|
||||||
"@tanstack/query-async-storage-persister": "^5.85.3",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-query": "^5.85.3",
|
"@tanstack/react-query-devtools": "^5.91.1",
|
||||||
"@tanstack/react-query-devtools": "^5.85.3",
|
"@tanstack/react-query-persist-client": "^5.90.14",
|
||||||
"@tanstack/react-query-persist-client": "^5.85.3",
|
"@types/bun": "^1.3.5",
|
||||||
"@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",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "15.1.6",
|
"next": "^16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"noblox.js": "^6.2.0",
|
"noblox.js": "^6.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.2.3",
|
||||||
"react-day-picker": "^9.8.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.2.3",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.69.0",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^4.0.15",
|
||||||
"recharts": "2.15.4",
|
"recharts": "3.6.0",
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.0.5"
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"typescript": "^5.9.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19.2.3",
|
||||||
"postcss": "^8",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"tailwindcss": "^3.4.1"
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
/** @type {import('postcss-load-config').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {}
|
"@tailwindcss/postcss": {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 9.4 KiB |
BIN
public/favicon2.ico
Normal file
BIN
public/favicon2.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
public/roblox.png
Normal file
BIN
public/roblox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
@@ -1,7 +1,7 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
darkMode: ["class"],
|
darkMode: "class",
|
||||||
content: [
|
content: [
|
||||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
@@ -12,6 +12,19 @@ export default {
|
|||||||
colors: {
|
colors: {
|
||||||
background: "hsl(var(--background))",
|
background: "hsl(var(--background))",
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: "hsl(var(--foreground))",
|
||||||
|
base: "hsl(var(--ctp-base))",
|
||||||
|
mantle: "hsl(var(--ctp-mantle))",
|
||||||
|
crust: "hsl(var(--ctp-crust))",
|
||||||
|
text: "hsl(var(--ctp-text))",
|
||||||
|
subtext0: "hsl(var(--ctp-subtext0))",
|
||||||
|
subtext1: "hsl(var(--ctp-subtext1))",
|
||||||
|
surface0: "hsl(var(--ctp-surface0))",
|
||||||
|
surface1: "hsl(var(--ctp-surface1))",
|
||||||
|
surface2: "hsl(var(--ctp-surface2))",
|
||||||
|
blue: "hsl(var(--ctp-blue))",
|
||||||
|
green: "hsl(var(--ctp-green))",
|
||||||
|
yellow: "hsl(var(--ctp-yellow))",
|
||||||
|
red: "hsl(var(--ctp-red))",
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: "hsl(var(--card-foreground))"
|
foreground: "hsl(var(--card-foreground))"
|
||||||
@@ -60,10 +73,7 @@ export default {
|
|||||||
"accent-foreground":
|
"accent-foreground":
|
||||||
"hsl(var(--sidebar-accent-foreground))",
|
"hsl(var(--sidebar-accent-foreground))",
|
||||||
border: "hsl(var(--sidebar-border))",
|
border: "hsl(var(--sidebar-border))",
|
||||||
ring: "hsl(var(--sidebar-ring))",
|
ring: "hsl(var(--sidebar-ring))"
|
||||||
"primary-foreground":
|
|
||||||
"hsl(var(--sidebar-primary-foreground))",
|
|
||||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
@@ -72,22 +82,6 @@ export default {
|
|||||||
sm: "calc(var(--radius) - 4px)"
|
sm: "calc(var(--radius) - 4px)"
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"accordion-down": {
|
|
||||||
from: {
|
|
||||||
height: "0"
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
height: "var(--radix-accordion-content-height)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"accordion-up": {
|
|
||||||
from: {
|
|
||||||
height: "var(--radix-accordion-content-height)"
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
height: "0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"accordion-down": {
|
"accordion-down": {
|
||||||
from: {
|
from: {
|
||||||
height: "0"
|
height: "0"
|
||||||
@@ -106,19 +100,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out"
|
"accordion-up": "accordion-up 0.2s ease-out"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
require("tailwindcss-animate"),
|
require("tailwindcss-animate")
|
||||||
require("@tailwindcss/line-clamp"),
|
|
||||||
require("@catppuccin/tailwindcss")({
|
|
||||||
prefix: false,
|
|
||||||
defaultFlavour: "mocha"
|
|
||||||
})
|
|
||||||
]
|
]
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -1,27 +1,41 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
"allowJs": true,
|
"dom",
|
||||||
"skipLibCheck": true,
|
"dom.iterable",
|
||||||
"strict": true,
|
"esnext"
|
||||||
"noEmit": true,
|
],
|
||||||
"esModuleInterop": true,
|
"allowJs": true,
|
||||||
"module": "esnext",
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"strict": true,
|
||||||
"resolveJsonModule": true,
|
"noEmit": true,
|
||||||
"isolatedModules": true,
|
"esModuleInterop": true,
|
||||||
"jsx": "preserve",
|
"module": "esnext",
|
||||||
"incremental": true,
|
"moduleResolution": "bundler",
|
||||||
"plugins": [
|
"resolveJsonModule": true,
|
||||||
{
|
"isolatedModules": true,
|
||||||
"name": "next"
|
"jsx": "react-jsx",
|
||||||
}
|
"incremental": true,
|
||||||
],
|
"plugins": [
|
||||||
"paths": {
|
{
|
||||||
"@/*": ["./*"]
|
"name": "next"
|
||||||
}
|
}
|
||||||
},
|
],
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"paths": {
|
||||||
"exclude": ["node_modules"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user