diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 18154dd8..042951b0 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -12,6 +12,7 @@ import { import { useMobileNavigationStore } from '@/components/MobileNavigation' import { ModeToggle } from '@/components/ModeToggle' import { MobileSearch, Search } from '@/components/Search' +import { useAnnouncements } from '@/components/announcement-banner/AnnouncementBannerProvider' function TopLevelNavItem({ href, children }) { return ( @@ -29,6 +30,7 @@ function TopLevelNavItem({ href, children }) { export const Header = forwardRef(function Header({ className }, ref) { let { isOpen: mobileNavIsOpen } = useMobileNavigationStore() let isInsideMobileNavigation = useIsInsideMobileNavigation() + let { bannerHeight } = useAnnouncements() let { scrollY } = useScroll() let bgOpacityLight = useTransform(scrollY, [0, 72], [0.5, 0.9]) @@ -39,7 +41,7 @@ export const Header = forwardRef(function Header({ className }, ref) { ref={ref} className={clsx( className, - 'fixed inset-x-0 top-0 z-50 flex h-14 items-center justify-between gap-12 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80', + 'fixed inset-x-0 top-0 z-40 flex h-14 items-center justify-between gap-12 px-4 transition sm:px-6 lg:left-72 lg:z-30 lg:px-8 xl:left-80', !isInsideMobileNavigation && 'backdrop-blur-sm dark:backdrop-blur lg:left-72 xl:left-80', isInsideMobileNavigation @@ -49,6 +51,7 @@ export const Header = forwardRef(function Header({ className }, ref) { style={{ '--bg-opacity-light': bgOpacityLight, '--bg-opacity-dark': bgOpacityDark, + top: bannerHeight, }} >
-1 } + let { bannerHeight } = useAnnouncements() + return ( <> + -
+
@@ -190,7 +199,10 @@ export function Layout({ children, title, tableOfContents }) {
- {!router.route.startsWith("/ipa/resources") &&
+ {!router.route.startsWith("/ipa/resources") &&
  1. + ) : null} +
+ ) +} diff --git a/src/components/announcement-banner/AnnouncementBannerProvider.jsx b/src/components/announcement-banner/AnnouncementBannerProvider.jsx new file mode 100644 index 00000000..8fb6e14b --- /dev/null +++ b/src/components/announcement-banner/AnnouncementBannerProvider.jsx @@ -0,0 +1,93 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' + +import { useLocalStorage } from '@/hooks/useLocalStorage' + +const BANNER_ENABLED = true + +export const announcement = { + tag: '', + text: 'NetBird v0.60 Released - Native SSH Access', + link: 'https://netbird.io/knowledge-hub/native-identity-aware-ssh', + linkText: 'Read Release Article', + linkAlt: 'Learn more about the NetBird v0.60 release', + isExternal: false, + closeable: true, +} + +const AnnouncementContext = createContext({ + close: () => {}, + isVisible: false, + bannerHeight: 0, + reportHeight: () => {}, +}) + +export function AnnouncementBannerProvider({ children }) { + let [mounted, setMounted] = useState(false) + let [closedAnnouncement, setClosedAnnouncement] = useLocalStorage( + 'netbird-announcement', + undefined + ) + let announcementId = announcement.text + let [isHiddenByScroll, setIsHiddenByScroll] = useState(false) + let [bannerHeight, setBannerHeight] = useState(0) + + let close = () => { + setClosedAnnouncement(announcementId) + } + + let isActive = useMemo(() => { + if (!mounted) return false + if (!BANNER_ENABLED) return false + return closedAnnouncement !== announcementId + }, [announcementId, closedAnnouncement, mounted]) + + let isVisible = isActive && !isHiddenByScroll + + let reportHeight = useCallback((height) => { + setBannerHeight(height) + }, []) + + useEffect(() => { + setMounted(true) + return () => setMounted(false) + }, []) + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + function handleScroll() { + setIsHiddenByScroll(window.scrollY > 30) + } + + handleScroll() + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + useEffect(() => { + if (!isVisible && bannerHeight !== 0) { + setBannerHeight(0) + } + }, [bannerHeight, isVisible]) + + return ( + + {children} + + ) +} + +export function useAnnouncements() { + return useContext(AnnouncementContext) +} diff --git a/src/hooks/useCustomQueryURL.js b/src/hooks/useCustomQueryURL.js new file mode 100644 index 00000000..b98534b1 --- /dev/null +++ b/src/hooks/useCustomQueryURL.js @@ -0,0 +1,30 @@ +import { useMemo } from 'react' +import { useRouter } from 'next/router' + +export function useCustomQueryURL(target = '') { + let router = useRouter() + + return useMemo(() => { + if (!target) { + return '' + } + if (target.startsWith('http')) { + return target + } + + let asPath = router?.asPath ?? '' + let queryIndex = asPath.indexOf('?') + + if (queryIndex === -1) { + return target + } + + let currentQuery = asPath.slice(queryIndex + 1) + if (!currentQuery) { + return target + } + + let separator = target.includes('?') ? '&' : '?' + return `${target}${separator}${currentQuery}` + }, [router?.asPath, target]) +} diff --git a/src/hooks/useLocalStorage.js b/src/hooks/useLocalStorage.js new file mode 100644 index 00000000..915443a1 --- /dev/null +++ b/src/hooks/useLocalStorage.js @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from 'react' + +export function useLocalStorage(key, initialValue) { + let [storedValue, setStoredValue] = useState(() => { + if (typeof window === 'undefined') { + return initialValue + } + + try { + let item = window.localStorage.getItem(key) + return item ? JSON.parse(item) : initialValue + } catch { + return initialValue + } + }) + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + try { + let item = window.localStorage.getItem(key) + setStoredValue(item ? JSON.parse(item) : initialValue) + } catch { + setStoredValue(initialValue) + } + }, [initialValue, key]) + + let setValue = useCallback( + (value) => { + setStoredValue((previousValue) => { + let valueToStore = + typeof value === 'function' ? value(previousValue) : value + + if (typeof window !== 'undefined') { + if (valueToStore === undefined) { + window.localStorage.removeItem(key) + } else { + window.localStorage.setItem(key, JSON.stringify(valueToStore)) + } + } + + return valueToStore + }) + }, + [key] + ) + + return [storedValue, setValue] +} diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index 6c6c32d2..5cd6cb0a 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -13,6 +13,7 @@ import {slugifyWithCounter} from "@sindresorhus/slugify"; import {ToastContainer} from "react-toastify"; import 'react-toastify/dist/ReactToastify.css'; import {dom} from "@fortawesome/fontawesome-svg-core"; +import {AnnouncementBannerProvider} from "@/components/announcement-banner/AnnouncementBannerProvider"; function onRouteChange() { useMobileNavigationStore.getState().close() @@ -33,11 +34,13 @@ export default function App({ Component, pageProps }) { } - - - - - + + + + + + + ) diff --git a/tailwind.config.js b/tailwind.config.js index 9681c2c3..408d6c88 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -21,6 +21,9 @@ module.exports = { }, typography: require('./typography'), extend: { + colors: { + netbird: '#f68330', + }, boxShadow: { glow: '0 0 4px rgb(0 0 0 / 0.1)', },