From 8352d156f841a00cf6c235a9f8bb6d9bc506c26e Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 27 Jan 2026 10:02:20 +0100 Subject: [PATCH] Update announcement --- package.json | 1 + .../AnnouncementBanner.jsx | 64 ++++---- .../AnnouncementBannerProvider.jsx | 155 +++++++++++------- 3 files changed, 124 insertions(+), 96 deletions(-) diff --git a/package.json b/package.json index a7db7468..0cc9ac83 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "allof-merge": "^0.6.6", "autoprefixer": "^10.4.7", "clsx": "^1.2.0", + "crypto-js": "^4.2.0", "ejs": "^3.1.9", "focus-visible": "^5.2.0", "framer-motion": "10.12.9", diff --git a/src/components/announcement-banner/AnnouncementBanner.jsx b/src/components/announcement-banner/AnnouncementBanner.jsx index a958b09b..1ae0e96f 100644 --- a/src/components/announcement-banner/AnnouncementBanner.jsx +++ b/src/components/announcement-banner/AnnouncementBanner.jsx @@ -1,11 +1,7 @@ -import { useEffect, useRef } from 'react' import clsx from 'clsx' import Link from 'next/link' -import { - announcement, - useAnnouncements, -} from '@/components/announcement-banner/AnnouncementBannerProvider' +import { useAnnouncements } from '@/components/announcement-banner/AnnouncementBannerProvider' import { useCustomQueryURL } from '@/hooks/useCustomQueryURL' function ArrowRightIcon(props) { @@ -38,39 +34,11 @@ function CloseIcon(props) { ) } -export function AnnouncementBanner() { - let { isVisible, close, reportHeight } = useAnnouncements() - let announcementLink = useCustomQueryURL(announcement.link || '') - let bannerRef = useRef(null) - - useEffect(() => { - if (!isVisible) { - reportHeight(0) - return - } - - function updateHeight() { - if (bannerRef.current) { - reportHeight(bannerRef.current.offsetHeight || 0) - } else { - reportHeight(0) - } - } - - updateHeight() - window.addEventListener('resize', updateHeight) - return () => { - window.removeEventListener('resize', updateHeight) - } - }, [isVisible, reportHeight]) - - if (!isVisible) { - return null - } +function AnnouncementItem({ announcement, onClose }) { + const announcementLink = useCustomQueryURL(announcement.link || '') return (
onClose(announcement.hash)} className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-black transition hover:bg-black/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black" aria-label="Dismiss announcement" > @@ -110,3 +78,27 @@ export function AnnouncementBanner() {
) } + +export function AnnouncementBanner() { + const { announcements, closeAnnouncement } = useAnnouncements() + + if (!announcements) { + return null + } + + const openAnnouncements = announcements.filter((a) => a.isOpen) + + if (openAnnouncements.length === 0) { + return null + } + + // Show the first open announcement + const announcement = openAnnouncements[0] + + return ( + + ) +} \ No newline at end of file diff --git a/src/components/announcement-banner/AnnouncementBannerProvider.jsx b/src/components/announcement-banner/AnnouncementBannerProvider.jsx index 94a860cf..51f17ac0 100644 --- a/src/components/announcement-banner/AnnouncementBannerProvider.jsx +++ b/src/components/announcement-banner/AnnouncementBannerProvider.jsx @@ -1,87 +1,122 @@ +import md5 from 'crypto-js/md5' import { createContext, useCallback, useContext, useEffect, - useMemo, + useRef, useState, } from 'react' -import { useLocalStorage } from '@/hooks/useLocalStorage' - -const BANNER_ENABLED = true - -export const announcement = { - tag: 'New', - text: 'Simplified IdP Integration', - link: '/selfhosted/identity-providers', - linkText: 'Learn More', - linkAlt: 'Learn more about the embedded Identity Provider powered by DEX for self-hosted installations', - isExternal: false, - closeable: true, -} +const ANNOUNCEMENTS_URL = + 'https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json' +const STORAGE_KEY = 'netbird-announcements' +const CACHE_DURATION_MS = 30 * 60 * 1000 +const BANNER_HEIGHT = 33 const AnnouncementContext = createContext({ - close: () => {}, - isVisible: false, bannerHeight: 0, - reportHeight: () => {}, + announcements: undefined, + closeAnnouncement: () => {}, }) -export function AnnouncementBannerProvider({ children }) { - let [mounted, setMounted] = useState(false) - let [closedAnnouncement, setClosedAnnouncement] = useLocalStorage( - 'netbird-announcement', - undefined - ) - let announcementId = announcement.text - let [bannerHeight, setBannerHeight] = useState(0) +const getAnnouncements = async () => { + try { + let stored = null + try { + const data = localStorage.getItem(STORAGE_KEY) + stored = data ? JSON.parse(data) : null + } catch {} - let close = () => { - setClosedAnnouncement(announcementId) - } - - let isActive = useMemo(() => { - if (!mounted) return false - if (!BANNER_ENABLED) return false - return closedAnnouncement !== announcementId - }, [announcementId, closedAnnouncement, mounted]) + const now = Date.now() - let isVisible = isActive // Always visible when active, regardless of scroll + let raw - let reportHeight = useCallback((height) => { - setBannerHeight(height) - }, []) + if (stored && now - stored.timestamp < CACHE_DURATION_MS) { + raw = stored.announcements + } else { + const response = await fetch(ANNOUNCEMENTS_URL) + if (!response.ok) return [] - useEffect(() => { - setMounted(true) - return () => setMounted(false) - }, []) + raw = await response.json() + } - // Removed scroll-based hiding to make banner always sticky - // useEffect(() => { - // if (typeof window === 'undefined') { - // return - // } + // Filter announcements - show all for docs site (not cloud-specific) + const filtered = raw.filter((a) => !a.isCloudOnly) + const hashes = new Set(filtered.map((a) => md5(a.text).toString())) + const closed = (stored?.closedAnnouncements ?? []).filter((h) => + hashes.has(h) + ) + + try { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + timestamp: now, + announcements: raw, + closedAnnouncements: closed, + }) + ) + } catch {} + + return filtered.map((a) => { + const hash = md5(a.text).toString() + return { ...a, hash, isOpen: !closed.includes(hash) } + }) + } catch { + return [] + } +} - // function handleScroll() { - // setIsHiddenByScroll(window.scrollY > 30) - // } +const saveAnnouncements = (closedAnnouncements) => { + try { + const data = localStorage.getItem(STORAGE_KEY) + const stored = data ? JSON.parse(data) : null + if (stored) { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ ...stored, closedAnnouncements }) + ) + } + } catch {} +} - // handleScroll() - // window.addEventListener('scroll', handleScroll, { passive: true }) - // return () => window.removeEventListener('scroll', handleScroll) - // }, []) +export function AnnouncementBannerProvider({ children }) { + const [announcements, setAnnouncements] = useState(undefined) + const fetchingRef = useRef(false) useEffect(() => { - if (!isVisible && bannerHeight !== 0) { - setBannerHeight(0) - } - }, [bannerHeight, isVisible]) + if (announcements !== undefined || fetchingRef.current) return + fetchingRef.current = true + getAnnouncements() + .then((a) => setAnnouncements(a)) + .finally(() => (fetchingRef.current = false)) + }, [announcements]) + + const closeAnnouncement = useCallback( + (hash) => { + if (!announcements) return + const updated = announcements.map((a) => + a.hash === hash ? { ...a, isOpen: false } : a + ) + const closedAnnouncements = updated + .filter((a) => !a.isOpen) + .map((a) => a.hash) + saveAnnouncements(closedAnnouncements) + setAnnouncements(updated) + }, + [announcements] + ) + + const bannerHeight = announcements?.some((a) => a.isOpen) ? BANNER_HEIGHT : 0 return ( {children} @@ -90,4 +125,4 @@ export function AnnouncementBannerProvider({ children }) { export function useAnnouncements() { return useContext(AnnouncementContext) -} +} \ No newline at end of file