Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 28 additions & 36 deletions src/components/announcement-banner/AnnouncementBanner.jsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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 (
<div
ref={bannerRef}
id="announcement-banner"
className={clsx(
'sticky top-0 z-50 flex w-full items-center justify-center border-b border-zinc-800 bg-netbird/95 px-4 py-1.5 text-[11px] font-medium text-black shadow-sm backdrop-blur'
Expand Down Expand Up @@ -100,7 +68,7 @@ export function AnnouncementBanner() {
{announcement.closeable ? (
<button
type="button"
onClick={close}
onClick={() => 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"
>
Expand All @@ -110,3 +78,27 @@ export function AnnouncementBanner() {
</div>
)
}

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 (
<AnnouncementItem
announcement={announcement}
onClose={closeAnnouncement}
/>
)
}
155 changes: 95 additions & 60 deletions src/components/announcement-banner/AnnouncementBannerProvider.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<AnnouncementContext.Provider
value={{ close, isVisible, bannerHeight, reportHeight }}
value={{
bannerHeight,
announcements,
closeAnnouncement,
}}
>
{children}
</AnnouncementContext.Provider>
Expand All @@ -90,4 +125,4 @@ export function AnnouncementBannerProvider({ children }) {

export function useAnnouncements() {
return useContext(AnnouncementContext)
}
}