diff --git a/Frontend/bun.lockb b/Frontend/bun.lockb index bacd45c..61d9b92 100644 Binary files a/Frontend/bun.lockb and b/Frontend/bun.lockb differ diff --git a/Frontend/package.json b/Frontend/package.json index fa92606..a2f1f17 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -13,10 +13,13 @@ "dependencies": { "@heroicons/react": "^2.1.3", "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "class-variance-authority": "^0.7.0", + "clipboard-copy": "^4.0.1", "clsx": "^2.1.0", "lucide-react": "^0.370.0", "next": "14.2.1", diff --git a/Frontend/src/app/(pages)/player/[[...slug]]/page.tsx b/Frontend/src/app/(pages)/player/[[...slug]]/page.tsx index 1a2052e..138fe4c 100644 --- a/Frontend/src/app/(pages)/player/[[...slug]]/page.tsx +++ b/Frontend/src/app/(pages)/player/[[...slug]]/page.tsx @@ -59,7 +59,7 @@ const PlayerPage = async ({ params }: PageProps): Promise => { {/* Player Result */} - {result && } + {result && } @@ -92,11 +92,13 @@ export const generateMetadata = async ({ if (code === 400) { return Embed({ title: "Invalid Player", + color: "#EB4034", description: "The player you searched for is invalid.", }); } else if (code === 404) { return Embed({ title: "Player Not Found", + color: "#EB4034", description: "The player you searched for was not found.", }); } diff --git a/Frontend/src/app/components/copy-button.tsx b/Frontend/src/app/components/copy-button.tsx new file mode 100644 index 0000000..d51fcf3 --- /dev/null +++ b/Frontend/src/app/components/copy-button.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { ReactElement, ReactNode } from "react"; +import copy from "clipboard-copy"; + +/** + * Props for the copy button. + */ +type CopyButtonProps = { + /** + * The content to copy when + * this component is clicked. + */ + content: string; + + /** + * The children to render in this button. + */ + children: ReactNode; +}; + +/** + * A component that copies + */ +const CopyButton = ({ content, children }: CopyButtonProps): ReactElement => ( + +); +export default CopyButton; diff --git a/Frontend/src/app/components/embed.tsx b/Frontend/src/app/components/embed.tsx index cfdcabc..c82d51f 100644 --- a/Frontend/src/app/components/embed.tsx +++ b/Frontend/src/app/components/embed.tsx @@ -9,6 +9,12 @@ type EmbedProps = { */ title: string; + /** + * The color of this embed, undefined + * for no custom color. + */ + color?: string | undefined; + /** * The description of the embed. */ @@ -28,6 +34,7 @@ type EmbedProps = { */ const Embed = ({ title, + color, description, thumbnail = "", }: EmbedProps): Metadata => { @@ -45,6 +52,7 @@ const Embed = ({ twitter: { card: "summary", }, + themeColor: color, }; }; export default Embed; diff --git a/Frontend/src/app/components/landing/statistic-counters.tsx b/Frontend/src/app/components/landing/statistic-counters.tsx index 7fd3b7f..633ea73 100644 --- a/Frontend/src/app/components/landing/statistic-counters.tsx +++ b/Frontend/src/app/components/landing/statistic-counters.tsx @@ -8,7 +8,7 @@ import { ReactElement } from "react"; */ const StatisticCounters = (): ReactElement => (
-
+
diff --git a/Frontend/src/app/components/player/player-result.tsx b/Frontend/src/app/components/player/player-result.tsx index 9acc54d..8bb43d6 100644 --- a/Frontend/src/app/components/player/player-result.tsx +++ b/Frontend/src/app/components/player/player-result.tsx @@ -4,6 +4,14 @@ import { CachedPlayer, SkinPart } from "restfulmc-lib"; import { ReactElement } from "react"; import { Badge } from "@/components/ui/badge"; import config from "@/config"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import CopyButton from "@/components/copy-button"; +import * as querystring from "node:querystring"; /** * The result of a player search. @@ -12,6 +20,7 @@ import config from "@/config"; * @returns the player result jsx */ const PlayerResult = ({ + query, player: { uniqueId, username, @@ -19,77 +28,99 @@ const PlayerResult = ({ legacy, }, }: { + query: string; player: CachedPlayer; }): ReactElement => ( -
- {/* Raw Json */} -
- - - Raw Json - - -
+ + +
+ {/* Raw Json */} +
+ + + Raw Json + + +
-
- {/* Details */} -
- {/* Player Head */} - {`${username}'s +
+ {/* Details */} +
+ {/* Player Head */} + {`${username}'s - {/* Name, Unique ID, and Badges */} -
-

- {username} -

- - {uniqueId} - + {/* Name, Unique ID, and Badges */} +
+

+ {username} +

+ + {uniqueId} + - {/* Legacy Badge */} - {legacy && ( -

- Legacy -

- )} + {/* Legacy Badge */} + {legacy && ( +

+ Legacy +

+ )} +
+
+ + {/* Skin Parts */} +
+ {/* Header */} +

Skin Parts

+ + {/* Skin Parts */} +
+ {Object.entries(parts) + .filter( + ([part]) => + part === SkinPart.HEAD || + part === SkinPart.FACE || + part === SkinPart.BODY_FLAT + ) + .map(([part, url], index) => ( + + {`${username}'s + + ))} +
+
- - {/* Skin Parts */} -
- {/* Header */} -

Skin Parts

- - {/* Skin Parts */} -
- {Object.entries(parts) - .filter( - ([part]) => - part === SkinPart.HEAD || - part === SkinPart.FACE || - part === SkinPart.BODY_FLAT - ) - .map(([part, url], index) => ( - - {`${username}'s - - ))} -
-
-
-
+ + + + Copy Player Username + + + Copy Player UUID + + + + Copy Share URL + + + + ); export default PlayerResult; diff --git a/Frontend/src/app/components/ui/context-menu.tsx b/Frontend/src/app/components/ui/context-menu.tsx new file mode 100644 index 0000000..2bd9c6b --- /dev/null +++ b/Frontend/src/app/components/ui/context-menu.tsx @@ -0,0 +1,200 @@ +"use client" + +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/app/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/Frontend/src/app/layout.tsx b/Frontend/src/app/layout.tsx index 8f2ba0b..6708bd2 100644 --- a/Frontend/src/app/layout.tsx +++ b/Frontend/src/app/layout.tsx @@ -1,4 +1,3 @@ -import Footer from "@/components/footer"; import Navbar from "@/components/navbar"; import { TooltipProvider } from "@/components/ui/tooltip"; import config from "@/config"; diff --git a/Frontend/tailwind.config.ts b/Frontend/tailwind.config.ts index 94bcd01..609822f 100644 --- a/Frontend/tailwind.config.ts +++ b/Frontend/tailwind.config.ts @@ -1,86 +1,86 @@ import type { Config } from "tailwindcss"; -const { fontFamily, screens } = require("tailwindcss/defaultTheme"); +const { screens } = require("tailwindcss/defaultTheme"); const config = { - darkMode: ["class"], - content: ["./src/app/**/*.{ts,tsx}"], - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, + darkMode: ["class"], + content: ["./src/app/**/*.{ts,tsx}"], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, - // Custom Colors - "navbar-background": "hsl(var(--navbar-background))", - "minecraft-green-1": "hsl(var(--minecraft-green-1))", - "minecraft-green-2": "hsl(var(--minecraft-green-2))", - "minecraft-green-3": "hsl(var(--minecraft-green-3))", - "minecraft-green-4": "hsl(var(--minecraft-green-4))", - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - 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" }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, - screens: { - xs: "475px", - ...screens, - }, - }, - plugins: [require("tailwindcss-animate")], + // Custom Colors + "navbar-background": "hsl(var(--navbar-background))", + "minecraft-green-1": "hsl(var(--minecraft-green-1))", + "minecraft-green-2": "hsl(var(--minecraft-green-2))", + "minecraft-green-3": "hsl(var(--minecraft-green-3))", + "minecraft-green-4": "hsl(var(--minecraft-green-4))", + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + 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" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + screens: { + xs: "475px", + ...screens, + }, + }, + plugins: [require("tailwindcss-animate")], } satisfies Config; export default config;