Skip to content

Add in-site notification feature to Docusaurus #814

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
May 9, 2025
Merged
Show file tree
Hide file tree
Changes from 15 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
8 changes: 7 additions & 1 deletion docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
// See: https://docusaurus.io/docs/api/docusaurus-config

import {themes as prismThemes} from 'prism-react-renderer';
import { getNotifications } from './src/data/notifications';

/** @type {import('@docusaurus/types').Config} */
const config = {
title: 'ScalarDL Documentation',
tagline: 'Scalable and practical byzantine-fault detection middleware for transactional database systems',
Expand Down Expand Up @@ -276,6 +276,12 @@ const config = {
type: 'localeDropdown',
position: 'right',
},
// Custom notification function as a React component. Update the notification messages in the /src/data/notifications.js file.
{
type: 'custom-NotificationBell',
position: 'right',
notifications: getNotifications(),
},
{
href: 'https://github.com/scalar-labs/scalardl',
position: 'right',
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@docusaurus/plugin-pwa": "^3.7.0",
"@docusaurus/preset-classic": "^3.7.0",
"@docusaurus/theme-mermaid": "^3.7.0",
"@fortawesome/fontawesome-free": "6.6.0",
"@fortawesome/fontawesome-svg-core": "6.5.2",
"@fortawesome/free-brands-svg-icons": "6.5.2",
"@fortawesome/free-regular-svg-icons": "6.5.2",
Expand Down
99 changes: 99 additions & 0 deletions src/components/NotificationBell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React, { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBell } from '@fortawesome/free-regular-svg-icons';
import { detectLanguage } from '../data/notifications';

const NotificationBell = ({ notifications }) => {
const [isOpen, setIsOpen] = useState(false);
const [notificationList, setNotificationList] = useState([]);
const [currentLanguage, setCurrentLanguage] = useState('en');
const dropdownRef = useRef(null);
const wrapperRef = useRef(null);

// Set the current language in the component.
useEffect(() => {
setCurrentLanguage(detectLanguage());
}, []);

// Toggle dropdown visibility and prevent the event from bubbling up to the outside click handler.
const toggleDropdown = (event) => {
event.stopPropagation(); // Prevent outside click handler from immediately reopening dropdown.
setIsOpen((prev) => !prev);
};

// Load notifications from localStorage and update it if there are new notifications.
useEffect(() => {
// Retrieve seen notifications from localStorage.
const seenNotifications = JSON.parse(localStorage.getItem('seenNotifications')) || [];

// Map the notifications to add read status based on seenNotifications.
const updatedNotifications = notifications.map(notification => ({
...notification,
read: seenNotifications.includes(notification.id),
}));

// Set the updated notifications in state.
setNotificationList(updatedNotifications);
}, [notifications]); // Dependency ensures rerun if notifications change.

// Save changes to localStorage when notifications are clicked.
const handleNotificationClick = (notification, event) => {
// If it's an internal link, prevent default behavior and use history to navigate.
if (!notification.isExternal) {
event.preventDefault();
window.location.href = notification.url;
}

const updatedList = notificationList.map(notif =>
notif.id === notification.id ? { ...notif, read: true } : notif
);

// Save the seen notifications in localStorage.
const seenNotifications = updatedList
.filter(notif => notif.read)
.map(notif => notif.id);

localStorage.setItem('seenNotifications', JSON.stringify(seenNotifications));
setNotificationList(updatedList); // Update the notification list with a read status.
};

// Count unread notifications.
const unreadCount = notificationList.filter(notification => !notification.read).length;

// Close the dropdown when clicking outside.
useEffect(() => {
const handleClickOutside = (event) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
setIsOpen(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

return (
<div className="notification-wrapper" onClick={toggleDropdown} ref={wrapperRef}>
<i className="fa-solid fa-bell"></i><FontAwesomeIcon icon={faBell} size="lg" />
{unreadCount > 0 && <span className="notification-count">{unreadCount}</span>}
{isOpen && (
<div className="notification-dropdown" ref={dropdownRef}>
{notificationList.map(notification => (
<a
key={notification.id}
href={notification.url}
className={`notification-item ${!notification.read ? 'unread' : ''}`}
onClick={(e) => handleNotificationClick(notification, e)}
target={notification.isExternal ? '_blank' : '_self'}
rel={notification.isExternal ? 'noopener noreferrer' : undefined}
>
{notification.message}
</a>
))}
</div>
)}
</div>
);
};

export default NotificationBell;
131 changes: 130 additions & 1 deletion src/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ html[data-theme="dark"] a[class^='fa-solid fa-circle-question'] {
text-wrap: nowrap;
visibility: hidden;
width: auto;
z-index: 1;
z-index: 10;
top: -2px;
left: 125%;
}
Expand Down Expand Up @@ -365,3 +365,132 @@ html[data-theme="dark"] .container img[src$=".png"] { /* Adds a white background
z-index: 20;
position: relative;
}

.medium-zoom-image--opened {
background-color: #ffffff;
}

/* In-site notification feature */
.notification-wrapper {
position: relative;
cursor: pointer;
padding: 8px;
}

.notification-count {
position: absolute;
top: 0;
right: 0;
background-color: #ff4444;
color: white;
border-radius: 50%;
padding: 0.2rem 0.2rem;
font-size: 0.8rem;
min-width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transform: translate(30%, 15%);
}

.notification-dropdown {
background-color: var(--ifm-dropdown-background-color);
box-shadow: var(--ifm-global-shadow-md);
border-radius: 8px;
font-size: 0.95rem;
right: 0px;
position: absolute;
text-decoration: none;
top: 38px;
width: 330px;
z-index: 1000;
}

.notification-item {
border-bottom: 3px solid none;
color: inherit;
display: block;
padding: 10px;
text-decoration: none;
}

.notification-item.unread {
border-left: 5px solid #2673BB;
border-bottom: 3px solid none;
margin: 8px;
}

.notification-item {
transition: background-color 0.2s ease;
}

.notification-item:last-child {
border-bottom: none;
}

.notification-item:hover {
background-color: var(--ifm-dropdown-hover-background-color);
color: inherit;
text-decoration: none;
}

html[data-theme="dark"] .notification-item {
color: inherit;
padding: 10px;
}

html[data-theme="dark"] .notification-item:hover {
background-color: var(--ifm-dropdown-hover-background-color);
text-decoration: none;
}

html[data-theme="dark"] .notification-dropdown {
background-color: var(--ifm-dropdown-background-color);
box-shadow: var(--ifm-global-shadow-md);
}

/* Hide the notification icon in the main navbar on smaller screens */
@media (max-width: 997px) {
.notification-wrapper {
display: none; /* Hide in the main navbar on mobile */
position: sticky;
}
}

/* Show notification icon in the sidebar menu on mobile */
@media (max-width: 997px) {
.navbar-sidebar .notification-wrapper {
display: flex; /* Make it visible only in the sidebar on mobile */
margin-left: 5px;
position: relative;
}

.notification-count {
align-items: center;
background-color: red;
border-radius: 50%;
color: white;
display: flex;
font-size: 0.8rem;
height: 20px;
justify-content: center;
position: relative;
right: 12px;
top: -12px;
width: 20px;
}

.notification-dropdown {
background-color: var(--ifm-dropdown-background-color);
box-shadow: var(--ifm-global-shadow-md);
border-radius: 8px;
font-size: 0.875rem;
left: 0px;
position: absolute;
text-decoration: none;
top: 38px;
width: 330px;
z-index: 1000;
}
}
83 changes: 83 additions & 0 deletions src/data/notifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// This file contains the notifications data and a function to retrieve it.
// The notifications are stored in an array of objects, each containing a message in multiple languages and URLs for those messages.
const notificationsList = [
{
languages: {
en: 'Discover how to use generic contracts and functions in ScalarDL',
ja: 'ScalarDL で汎用コントラクトおよびファンクションの使用方法を学ぶ'
},
url: {
en: 'use-generic-contracts?utm_source=docs-site&utm_medium=notifications',
ja: 'use-generic-contracts?utm_source=docs-site&utm_medium=notifications'
},
unread: true
},
{
languages: {
en: 'Blog post: Migrating from Amazon QLDB to ScalarDL',
ja: 'ブログ記事: データベースエンジニアリングの最新トレンドとベストプラクティスを学ぶ DBEM #6 のハイライト'
},
url: {
en: 'https://medium.com/scalar-engineering/migrating-from-amazon-qldb-to-scalardl-ad6ffacbf598?utm_source=docs-site&utm_medium=notifications',
ja: 'https://medium.com/scalar-engineering-ja/database-engineering-meetup-%E7%AC%AC6%E5%9B%9E-dbem-6-%E3%82%92%E9%96%8B%E5%82%AC%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F-fccde39d2926?utm_source=docs-site&utm_medium=notifications'
},
unread: true
},
{
languages: {
en: 'Learn how to organize your data based on the ScalarDL data model',
ja: 'ScalarDL データモデルに基づいたデータの整理方法を学ぼう'
},
url: {
en: 'data-modeling?utm_source=docs-site&utm_medium=notifications',
ja: 'data-modeling?utm_source=docs-site&utm_medium=notifications'
},
unread: true
}
];

// Update the getNotifications function to handle both single URL and language-specific URLs, and prepend the correct base URL for relative paths.
export const getNotifications = (language = 'en') => {
const totalNotifications = notificationsList.length;

// Define base URLs for different languages.
const baseUrls = {
en: '/docs/latest/',
ja: '/ja-jp/docs/latest/'
};

const currentDomain = 'scalardl.scalar-labs.com';

return notificationsList
.map((notification, index) => {
// Get the appropriate URL for the language
let url = typeof notification.url === 'object'
? notification.url[language] || notification.url.en
: notification.url;

// If the URL is relative (doesn't start with http), prepend the appropriate base URL.
if (url && !url.startsWith('http')) {
url = baseUrls[language] + url;
}

// Check if the link is external by checking the domain.
const isExternal = url.startsWith('http') && !url.includes(currentDomain);

return {
id: totalNotifications - index,
message: notification.languages[language] || notification.languages.en,
url: url,
isExternal: isExternal, // Add this flag for the component to use.
unread: notification.unread
};
})
.sort((a, b) => b.id - a.id);
};

// Utility function that detects the language from the URL path.
export const detectLanguage = () => {
if (typeof window !== 'undefined') {
return window.location.pathname.includes('ja-jp') ? 'ja' : 'en';
}
return 'en'; // Default notifications to English if Japanese is not detected for some reason.
};
Loading