Skip to content

Commit 6e7a696

Browse files
committed
feat(site): add brand dropdown (#2812)
## Changes Added a context menu component to the UI library and implemented a logo context menu in the site header. The context menu allows users to copy the Rivet logo as SVG or download brand assets. The implementation includes: - Added `@radix-ui/react-context-menu` dependency - Created a new context menu component in the components package - Added a `LogoContextMenu` component that wraps the Rivet logo in the header - Implemented functionality to copy the logo as SVG and download brand assets
1 parent c235c8c commit 6e7a696

File tree

6 files changed

+363
-20
lines changed

6 files changed

+363
-20
lines changed

frontend/packages/components/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@radix-ui/react-accordion": "^1.1.2",
4242
"@radix-ui/react-avatar": "^1.0.4",
4343
"@radix-ui/react-checkbox": "^1.1.5",
44+
"@radix-ui/react-context-menu": "^2.2.15",
4445
"@radix-ui/react-dialog": "^1.1.7",
4546
"@radix-ui/react-dropdown-menu": "^2.1.7",
4647
"@radix-ui/react-label": "^2.1.3",

frontend/packages/components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export * from "./ui/kbd";
7171
export * from "./ui/checkbox";
7272
export { default as Filters } from "./ui/filters";
7373
export * from "./ui/filters";
74+
export * from "./ui/context-menu";
7475
export * from "./lib/utils";
7576
export * from "./lib/filesize";
7677
export * from "./lib/timing";
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
"use client";
2+
3+
import type * as React from "react";
4+
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
5+
import { Icon, faCheck, faChevronRight, faCircle } from "@rivet-gg/icons";
6+
import { cn } from "../lib/utils";
7+
8+
function ContextMenu({
9+
...props
10+
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
11+
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
12+
}
13+
14+
function ContextMenuTrigger({
15+
...props
16+
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
17+
return (
18+
<ContextMenuPrimitive.Trigger
19+
data-slot="context-menu-trigger"
20+
{...props}
21+
/>
22+
);
23+
}
24+
25+
function ContextMenuGroup({
26+
...props
27+
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
28+
return (
29+
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
30+
);
31+
}
32+
33+
function ContextMenuPortal({
34+
...props
35+
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
36+
return (
37+
<ContextMenuPrimitive.Portal
38+
data-slot="context-menu-portal"
39+
{...props}
40+
/>
41+
);
42+
}
43+
44+
function ContextMenuSub({
45+
...props
46+
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
47+
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
48+
}
49+
50+
function ContextMenuRadioGroup({
51+
...props
52+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
53+
return (
54+
<ContextMenuPrimitive.RadioGroup
55+
data-slot="context-menu-radio-group"
56+
{...props}
57+
/>
58+
);
59+
}
60+
61+
function ContextMenuSubTrigger({
62+
className,
63+
inset,
64+
children,
65+
...props
66+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
67+
inset?: boolean;
68+
}) {
69+
return (
70+
<ContextMenuPrimitive.SubTrigger
71+
data-slot="context-menu-sub-trigger"
72+
data-inset={inset}
73+
className={cn(
74+
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
75+
className,
76+
)}
77+
{...props}
78+
>
79+
{children}
80+
<Icon icon={faChevronRight} className="ml-auto" />
81+
</ContextMenuPrimitive.SubTrigger>
82+
);
83+
}
84+
85+
function ContextMenuSubContent({
86+
className,
87+
...props
88+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
89+
return (
90+
<ContextMenuPrimitive.SubContent
91+
data-slot="context-menu-sub-content"
92+
className={cn(
93+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
94+
className,
95+
)}
96+
{...props}
97+
/>
98+
);
99+
}
100+
101+
function ContextMenuContent({
102+
className,
103+
...props
104+
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
105+
return (
106+
<ContextMenuPrimitive.Portal>
107+
<ContextMenuPrimitive.Content
108+
data-slot="context-menu-content"
109+
className={cn(
110+
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
111+
className,
112+
)}
113+
{...props}
114+
/>
115+
</ContextMenuPrimitive.Portal>
116+
);
117+
}
118+
119+
function ContextMenuItem({
120+
className,
121+
inset,
122+
variant = "default",
123+
...props
124+
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
125+
inset?: boolean;
126+
variant?: "default" | "destructive";
127+
}) {
128+
return (
129+
<ContextMenuPrimitive.Item
130+
data-slot="context-menu-item"
131+
data-inset={inset}
132+
data-variant={variant}
133+
className={cn(
134+
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
135+
className,
136+
)}
137+
{...props}
138+
/>
139+
);
140+
}
141+
142+
function ContextMenuCheckboxItem({
143+
className,
144+
children,
145+
checked,
146+
...props
147+
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
148+
return (
149+
<ContextMenuPrimitive.CheckboxItem
150+
data-slot="context-menu-checkbox-item"
151+
className={cn(
152+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
153+
className,
154+
)}
155+
checked={checked}
156+
{...props}
157+
>
158+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
159+
<ContextMenuPrimitive.ItemIndicator>
160+
<Icon icon={faCheck} className="size-4" />
161+
</ContextMenuPrimitive.ItemIndicator>
162+
</span>
163+
{children}
164+
</ContextMenuPrimitive.CheckboxItem>
165+
);
166+
}
167+
168+
function ContextMenuRadioItem({
169+
className,
170+
children,
171+
...props
172+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
173+
return (
174+
<ContextMenuPrimitive.RadioItem
175+
data-slot="context-menu-radio-item"
176+
className={cn(
177+
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
178+
className,
179+
)}
180+
{...props}
181+
>
182+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
183+
<ContextMenuPrimitive.ItemIndicator>
184+
<Icon icon={faCircle} className="size-2 fill-current" />
185+
</ContextMenuPrimitive.ItemIndicator>
186+
</span>
187+
{children}
188+
</ContextMenuPrimitive.RadioItem>
189+
);
190+
}
191+
192+
function ContextMenuLabel({
193+
className,
194+
inset,
195+
...props
196+
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
197+
inset?: boolean;
198+
}) {
199+
return (
200+
<ContextMenuPrimitive.Label
201+
data-slot="context-menu-label"
202+
data-inset={inset}
203+
className={cn(
204+
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
205+
className,
206+
)}
207+
{...props}
208+
/>
209+
);
210+
}
211+
212+
function ContextMenuSeparator({
213+
className,
214+
...props
215+
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
216+
return (
217+
<ContextMenuPrimitive.Separator
218+
data-slot="context-menu-separator"
219+
className={cn("bg-border -mx-1 my-1 h-px", className)}
220+
{...props}
221+
/>
222+
);
223+
}
224+
225+
function ContextMenuShortcut({
226+
className,
227+
...props
228+
}: React.ComponentProps<"span">) {
229+
return (
230+
<span
231+
data-slot="context-menu-shortcut"
232+
className={cn(
233+
"text-muted-foreground ml-auto text-xs tracking-widest",
234+
className,
235+
)}
236+
{...props}
237+
/>
238+
);
239+
}
240+
241+
export {
242+
ContextMenu,
243+
ContextMenuTrigger,
244+
ContextMenuContent,
245+
ContextMenuItem,
246+
ContextMenuCheckboxItem,
247+
ContextMenuRadioItem,
248+
ContextMenuLabel,
249+
ContextMenuSeparator,
250+
ContextMenuShortcut,
251+
ContextMenuGroup,
252+
ContextMenuPortal,
253+
ContextMenuSub,
254+
ContextMenuSubContent,
255+
ContextMenuSubTrigger,
256+
ContextMenuRadioGroup,
257+
};

site/src/components/v2/Header.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { type ReactNode, useEffect, useState } from "react";
1212
import { usePathname } from "next/navigation";
1313
import { GitHubDropdown } from "./GitHubDropdown";
1414
import { HeaderSearch } from "./HeaderSearch";
15+
import { LogoContextMenu } from "./LogoContextMenu";
1516

1617
interface TextNavItemProps {
1718
href: string;
@@ -100,16 +101,18 @@ export function Header({
100101
<RivetHeader
101102
className={headerStyles}
102103
logo={
103-
<Link href="/">
104-
<Image
105-
src={logoUrl.src || logoUrl}
106-
width={80}
107-
height={24}
108-
className="ml-1 w-20"
109-
alt="Rivet logo"
110-
unoptimized
111-
/>
112-
</Link>
104+
<LogoContextMenu>
105+
<Link href="/">
106+
<Image
107+
src={logoUrl.src || logoUrl}
108+
width={80}
109+
height={24}
110+
className="ml-1 w-20"
111+
alt="Rivet logo"
112+
unoptimized
113+
/>
114+
</Link>
115+
</LogoContextMenu>
113116
}
114117
subnav={subnav}
115118
support={
@@ -197,16 +200,18 @@ export function Header({
197200
subnav ? "pb-2 md:pb-0 md:pt-4" : "md:py-4",
198201
)}
199202
logo={
200-
<Link href="/">
201-
<Image
202-
src={logoUrl.src || logoUrl}
203-
width={80}
204-
height={24}
205-
className="ml-1 w-20"
206-
alt="Rivet logo"
207-
unoptimized
208-
/>
209-
</Link>
203+
<LogoContextMenu>
204+
<Link href="/">
205+
<Image
206+
src={logoUrl.src || logoUrl}
207+
width={80}
208+
height={24}
209+
className="ml-1 w-20"
210+
alt="Rivet logo"
211+
unoptimized
212+
/>
213+
</Link>
214+
</LogoContextMenu>
210215
}
211216
subnav={subnav}
212217
support={
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"use client";
2+
import { useRef } from "react";
3+
import {
4+
ContextMenu,
5+
ContextMenuContent,
6+
ContextMenuItem,
7+
ContextMenuTrigger,
8+
toast,
9+
} from "@rivet-gg/components";
10+
import { Icon, faCopy, faDownload } from "@rivet-gg/icons";
11+
12+
import logoUrl from "@/images/rivet-logos/icon-text-white.svg";
13+
14+
interface LogoContextMenuProps {
15+
children: React.ReactNode;
16+
}
17+
18+
export function LogoContextMenu({ children }: LogoContextMenuProps) {
19+
const menuRef = useRef<HTMLDivElement>(null);
20+
21+
const copyLogoAsSVG = async () => {
22+
try {
23+
const response = await fetch(logoUrl.src);
24+
const svgContent = await response.text();
25+
26+
await navigator.clipboard.writeText(svgContent);
27+
toast.success("Logo copied as SVG!");
28+
} catch (err) {
29+
toast.error("Failed to copy logo as SVG.");
30+
}
31+
};
32+
33+
const downloadBrandAssets = () => {
34+
window.open("https://releases.rivet.gg/press-kit.zip", "_blank");
35+
};
36+
37+
return (
38+
<>
39+
<ContextMenu>
40+
<ContextMenuTrigger>{children}</ContextMenuTrigger>
41+
<ContextMenuContent ref={menuRef}>
42+
<ContextMenuItem onClick={copyLogoAsSVG}>
43+
<Icon icon={faCopy} className="mr-3 h-4 w-4" />
44+
Copy logo as SVG
45+
</ContextMenuItem>
46+
<ContextMenuItem onClick={downloadBrandAssets}>
47+
<Icon icon={faDownload} className="mr-3 h-4 w-4" />
48+
Download brand assets
49+
</ContextMenuItem>
50+
</ContextMenuContent>
51+
</ContextMenu>
52+
</>
53+
);
54+
}

0 commit comments

Comments
 (0)