diff --git a/package-lock.json b/package-lock.json index b258390..25b334c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@tabler/icons-react": "^2.39.0", "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-table": "^8.10.7", + "@types/crypto-js": "^4.2.2", "@types/lodash": "^4.14.200", "@types/node": "20.10.6", "@types/react": "^18", @@ -35,6 +36,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^0.2.0", + "crypto-js": "^4.2.0", "date-fns": "^2.30.0", "dayjs": "^1.11.10", "eslint": "^8", @@ -1661,6 +1663,11 @@ "resolved": "https://registry.npmjs.org/@types/base64-js/-/base64-js-1.3.2.tgz", "integrity": "sha512-Q2Xn2/vQHRGLRXhQ5+BSLwhHkR3JVflxVKywH0Q6fVoAiUE8fFYL2pE5/l2ZiOiBDfA8qUqRnSxln4G/NFz1Sg==" }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==" + }, "node_modules/@types/jquery": { "version": "3.5.29", "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.29.tgz", @@ -2974,6 +2981,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/css-mediaquery": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", diff --git a/package.json b/package.json index d4ef715..c767a31 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@tabler/icons-react": "^2.39.0", "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-table": "^8.10.7", + "@types/crypto-js": "^4.2.2", "@types/lodash": "^4.14.200", "@types/node": "20.10.6", "@types/react": "^18", @@ -40,6 +41,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^0.2.0", + "crypto-js": "^4.2.0", "date-fns": "^2.30.0", "dayjs": "^1.11.10", "eslint": "^8", diff --git a/src/components/ui/AnnouncementBanner.tsx b/src/components/ui/AnnouncementBanner.tsx new file mode 100644 index 0000000..13a35e8 --- /dev/null +++ b/src/components/ui/AnnouncementBanner.tsx @@ -0,0 +1,92 @@ +import InlineLink from "@components/InlineLink"; +import { cn } from "@utils/helpers"; +import { cva, VariantProps } from "class-variance-authority"; +import { ArrowRightIcon, XIcon } from "lucide-react"; +import * as React from "react"; +import { useAnnouncement } from "@/contexts/AnnouncementProvider"; + +const variants = cva( + {}, + { + variants: { + variant: { + default: + "bg-nb-gray-900/50 border-nb-gray-800/30 border-b text-nb-gray-200", + important: "from-netbird to-netbird-400 bg-gradient-to-b text-white", + }, + tagBadge: { + default: "bg-nb-gray-200/10 text-nb-gray-100 font-medium", + important: "bg-white text-netbird font-medium", + }, + closeButton: { + default: + "bg-nb-gray-900 rounded-md p-1 text-nb-gray-300 hover:bg-nb-gray-800", + important: + "bg-netbird-100 rounded-md p-1 text-netbird-600 hover:bg-white", + }, + inlineLink: { + default: "text-nb-blue-400 hover:underline", + important: "!text-white underline hover:opacity-80", + }, + }, + }, +); + +export type AnnouncementVariant = VariantProps; + +export const AnnouncementBanner = () => { + const { bannerHeight, closeAnnouncement, announcements } = useAnnouncement(); + const announcement = announcements?.find((a) => a.isOpen); + + return announcement ? ( +
+
+ {announcement.tag && ( +
+ {announcement.tag} +
+ )} +
+ {announcement.text} + {announcement.link && ( + + {announcement.linkText || "Learn more"} + + + )} +
+
+ {announcement.closeable && ( +
+
closeAnnouncement(announcement.hash)} + > + +
+
+ )} +
+ ) : null; +}; diff --git a/src/contexts/AnnouncementProvider.tsx b/src/contexts/AnnouncementProvider.tsx new file mode 100644 index 0000000..5d2fc30 --- /dev/null +++ b/src/contexts/AnnouncementProvider.tsx @@ -0,0 +1,90 @@ +import { AnnouncementVariant } from "@components/ui/AnnouncementBanner"; +import { useLocalStorage } from "@hooks/useLocalStorage"; +import md5 from "crypto-js/md5"; +import React, { useEffect, useState } from "react"; + +const initialAnnouncements: Announcement[] = []; + +export interface Announcement extends AnnouncementVariant { + tag: string; + text: string; + link?: string; + linkText?: string; + isExternal?: boolean; + closeable: boolean; +} + +interface AnnouncementInfo extends Announcement { + isOpen: boolean; + hash: string; +} + +type Props = { + children: React.ReactNode; +}; + +const AnnouncementContext = React.createContext( + {} as { + bannerHeight: number; + announcements?: AnnouncementInfo[]; + closeAnnouncement: (hash: string) => void; + }, +); + +const bannerHeight = 40; + +export default function AnnouncementProvider({ children }: Props) { + const [height, setHeight] = useState(0); + const [closedAnnouncements, setClosedAnnouncements] = useLocalStorage< + string[] + >("netbird-closed-announcements", []); + const [announcements, setAnnouncements] = useState(); + + useEffect(() => { + const initial = initialAnnouncements.map((announcement) => { + const hash = md5(announcement.text).toString(); + const isOpen = !closedAnnouncements.some((h) => h === hash); + return { + ...announcement, + hash, + isOpen, + }; + }); + if (initial.length > 0) { + setAnnouncements(initial); + } + }, [closedAnnouncements]); + + const closeAnnouncement = (hash: string) => { + setClosedAnnouncements([...closedAnnouncements, hash]); + setAnnouncements(() => { + return announcements?.map((a) => { + if (a.hash === hash) { + return { ...a, isOpen: false }; + } + return a; + }); + }); + }; + + useEffect(() => { + const isAnnouncementOpen = announcements?.some((a) => a.isOpen); + if (isAnnouncementOpen) { + setHeight(bannerHeight); + } else { + setHeight(0); + } + }, [announcements]); + + return ( + + {children} + + ); +} + +export const useAnnouncement = () => { + return React.useContext(AnnouncementContext); +}; diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 1b08bb1..86137fe 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -11,6 +11,7 @@ import React from "react"; import { Toaster } from "react-hot-toast"; import OIDCProvider from "@/auth/OIDCProvider"; import AnalyticsProvider from "@/contexts/AnalyticsProvider"; +import AnnouncementProvider from "@/contexts/AnnouncementProvider"; import DialogProvider from "@/contexts/DialogProvider"; import ErrorBoundaryProvider from "@/contexts/ErrorBoundary"; import { GlobalThemeProvider } from "@/contexts/GlobalThemeProvider"; @@ -35,9 +36,11 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { - - {children} - + + + {children} + + diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/DashboardLayout.tsx index 237f6b7..adc98b2 100644 --- a/src/layouts/DashboardLayout.tsx +++ b/src/layouts/DashboardLayout.tsx @@ -9,6 +9,7 @@ import { useIsSm, useIsXs } from "@utils/responsive"; import { AnimatePresence, motion } from "framer-motion"; import { XIcon } from "lucide-react"; import React from "react"; +import { useAnnouncement } from "@/contexts/AnnouncementProvider"; import ApplicationProvider, { useApplicationContext, } from "@/contexts/ApplicationProvider"; @@ -16,7 +17,7 @@ import CountryProvider from "@/contexts/CountryProvider"; import GroupsProvider from "@/contexts/GroupsProvider"; import UsersProvider from "@/contexts/UsersProvider"; import Navigation from "@/layouts/Navigation"; -import Navbar from "./Header"; +import Navbar, { headerHeight } from "./Header"; export default function DashboardLayout({ children, @@ -43,7 +44,7 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) { const isXs = useIsXs(); const navOpenPageWidth = isSm ? "50%" : isXs ? "65%" : "80%"; - + const { bannerHeight } = useAnnouncement(); return (
{mobileNavOpen && ( @@ -148,7 +149,7 @@ function DashboardPageContent({ children }: { children: React.ReactNode }) {
diff --git a/src/layouts/Header.tsx b/src/layouts/Header.tsx index 0842849..514013c 100644 --- a/src/layouts/Header.tsx +++ b/src/layouts/Header.tsx @@ -1,17 +1,21 @@ "use client"; import Button from "@components/Button"; +import { AnnouncementBanner } from "@components/ui/AnnouncementBanner"; import DarkModeToggle from "@components/ui/DarkModeToggle"; import UserDropdown from "@components/ui/UserDropdown"; -import { Navbar } from "flowbite-react"; +import { cn } from "@utils/helpers"; import { MenuIcon } from "lucide-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useMemo } from "react"; +import React, { useMemo } from "react"; import NetBirdLogo from "@/assets/netbird.svg"; import NetBirdLogoFull from "@/assets/netbird-full.svg"; +import { useAnnouncement } from "@/contexts/AnnouncementProvider"; import { useApplicationContext } from "@/contexts/ApplicationProvider"; +export const headerHeight = 75; + export default function NavbarWithDropdown() { const router = useRouter(); const Logo = useMemo(() => { @@ -34,42 +38,55 @@ export default function NavbarWithDropdown() { }, []); const { toggleMobileNav } = useApplicationContext(); - + const { bannerHeight } = useAnnouncement(); return ( <> - -
- -
- router.push("/peers")} - className={"cursor-pointer hover:opacity-70 transition-all"} + +
- {Logo} - - -
-
- +
+ +
+
router.push("/peers")} + className={"cursor-pointer hover:opacity-70 transition-all"} + > + {Logo}
- +
+
+ +
+ + +
- -
+
+
); } diff --git a/tailwind.config.ts b/tailwind.config.ts index 2afec51..6fbd0d3 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -27,7 +27,6 @@ const config: Config = { "940": "#1b1f22", "950": "#181a1d", }, - netbird: { DEFAULT: "#f68330", "50": "#fff6ed", @@ -42,6 +41,20 @@ const config: Config = { "900": "#7a2b14", "950": "#421308", }, + "nb-blue": { + DEFAULT: "#31e4f5", + "50": "#ebffff", + "100": "#cefdff", + "200": "#a2f9ff", + "300": "#63f2fd", + "400": "#31e4f5", + "500": "#00c4da", + "600": "#039cb7", + "700": "#0a7c94", + "800": "#126478", + "900": "#145365", + "950": "#063746", + }, }, keyframes: { "accordion-down": {