Merge pull request #96 from Mintplex-Labs/v2-user-menu-notifications

v2 user menu notifications
This commit is contained in:
Timothy Carambat
2023-12-21 11:53:38 -08:00
committed by GitHub
7 changed files with 1544 additions and 249 deletions
@@ -1,195 +0,0 @@
import { ReactNode, useEffect, useState } from 'react';
import { AlertOctagon, AlertTriangle, Bell, Info } from 'react-feather';
import { useParams } from 'react-router-dom';
import Organization from '../../../models/organization';
import { databaseTimestampFromNow } from '../../../utils/data';
import ChromaLogo from '../../../images/vectordbs/chroma.png';
import PineconeLogo from '../../../images/vectordbs/pinecone.png';
import qDrantLogo from '../../../images/vectordbs/qdrant.png';
import WeaviateLogo from '../../../images/vectordbs/weaviate.png';
const POLLING_INTERVAL = 30_000;
export type INotification = {
id: number;
organization_id: number;
seen: boolean;
textContent: string;
symbol?:
| 'info'
| 'warning'
| 'error'
| 'chroma'
| 'pinecone'
| 'weaviate'
| 'qdrant';
link?: string;
target?: '_blank' | 'self';
createdAt: string;
lastUpdatedAt: string;
};
export default function Notifications() {
const { slug } = useParams();
const [loading, setLoading] = useState(true);
const [notifications, setNotifications] = useState<INotification[]>([]);
const [hasUnseen, setHasUnseen] = useState(false);
const [showNotifs, setShowNotifs] = useState(false);
async function handleClick() {
if (!showNotifs) {
!!slug && Organization.markNotificationsSeen(slug);
setShowNotifs(true);
setHasUnseen(false);
} else {
setShowNotifs(false);
}
}
async function fetchNotifications() {
if (!slug) {
setLoading(false);
return;
}
const { notifications: _notifications } = await Organization.notifications(
slug
);
setNotifications(_notifications);
setHasUnseen(_notifications.some((notif) => notif.seen === false));
setLoading(false);
}
useEffect(() => {
if (!slug) return;
fetchNotifications();
setInterval(() => {
fetchNotifications();
}, POLLING_INTERVAL);
}, [slug]);
if (loading) return null;
return (
<div className="relative">
<button
type="button"
onClick={handleClick}
className="group rounded-lg px-4 py-2 text-slate-800 hover:bg-slate-200"
>
<div className="relative">
<p
hidden={!hasUnseen}
className="absolute -top-[4px] right-0 h-[12px] w-[12px] rounded-full bg-red-600"
/>
<Bell
size={20}
className="group-hover:fill-blue-400 group-hover:stroke-blue-500"
/>
</div>
</button>
<div
hidden={!showNotifs}
className="absolute right-0 top-10 z-99 max-h-[50vh] w-[20rem] divide-y divide-gray-100 overflow-y-scroll rounded-lg bg-white shadow"
>
<div className="block rounded-t-lg bg-blue-100/50 bg-gray-50 px-4 py-2 text-center font-medium text-blue-700">
Recent Notifications
</div>
<div className="divide-y divide-gray-100">
{notifications.length === 0 ? (
<div className="flex px-4 py-3 hover:bg-gray-100">
<div className="w-full pl-3 text-center">
<div className="mb-1.5 text-xs text-gray-500">
no notifications
</div>
<div className="text-xs text-blue-600" />
</div>
</div>
) : (
<>
{notifications.map((notification) => (
<Notification
key={notification.id}
notification={notification}
/>
))}
</>
)}
</div>
</div>
</div>
);
}
function NotificationImage({ notification }: { notification: INotification }) {
switch (notification.symbol) {
case 'info':
return <Info className="text-blue-500" size={20} />;
case 'warning':
return <AlertTriangle className="text-orange-500" size={20} />;
case 'error':
return <AlertOctagon className="text-red-600" size={20} />;
case 'chroma':
return <img className="h-10 w-10 rounded-full" src={ChromaLogo} />;
case 'pinecone':
return <img className="h-8 w-8 rounded-full" src={PineconeLogo} />;
case 'qdrant':
return <img className="h-8 w-8 rounded-full" src={qDrantLogo} />;
case 'weaviate':
return <img className="h-8 w-8 rounded-full" src={WeaviateLogo} />;
default:
return <Info className="text-blue-500" size={20} />;
}
}
function NotificationWrapper({
notification,
children,
}: {
notification: INotification;
children: ReactNode;
}) {
if (!!notification.link) {
return (
<a
key={notification.id}
href={notification?.link || '#'}
target={notification?.target || 'self'}
className={`flex px-4 py-3 hover:bg-gray-100 ${
!notification.seen ? 'border-l-4 !border-l-blue-600' : ''
}`}
>
{children}
</a>
);
}
return (
<div
key={notification.id}
className={`flex px-4 py-3 hover:bg-gray-100 ${
!notification.seen ? 'border-l-4 !border-l-blue-600' : ''
}`}
>
{children}
</div>
);
}
function Notification({ notification }: { notification: INotification }) {
return (
<NotificationWrapper notification={notification}>
<div className="flex flex-shrink-0 items-center justify-center">
<NotificationImage notification={notification} />
</div>
<div className="w-full pl-3">
<div className="mb-1.5 text-sm text-gray-500">
{notification.textContent}
</div>
<div className="text-xs text-blue-600">
{databaseTimestampFromNow(notification.createdAt)}
</div>
</div>
</NotificationWrapper>
);
}
@@ -0,0 +1,391 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import Organization from '../../models/organization';
import { databaseTimestampFromNow } from '../../utils/data';
import ChromaLogo from '../../images/vectordbs/chroma.png';
import PineconeLogo from '../../images/vectordbs/pinecone-inverted.png';
import qDrantLogo from '../../images/vectordbs/qdrant.png';
import WeaviateLogo from '../../images/vectordbs/weaviate.png';
import { Bell, Info, Warning, WarningOctagon } from '@phosphor-icons/react';
const POLLING_INTERVAL = 30_000;
export type INotification = {
id: number;
organization_id: number;
seen: boolean;
textContent: string;
symbol?:
| 'info'
| 'warning'
| 'error'
| 'chroma'
| 'pinecone'
| 'weaviate'
| 'qdrant';
link?: string;
target?: '_blank' | 'self';
createdAt: string;
lastUpdatedAt: string;
};
export default function Notifications() {
const { slug } = useParams();
const [loading, setLoading] = useState(true);
const [notifications, setNotifications] = useState<INotification[]>([]);
const [hasUnseen, setHasUnseen] = useState(false);
const [showNotifs, setShowNotifs] = useState(false);
const notificationRef = useRef(null);
const bellButtonRef = useRef(null);
async function handleClick() {
if (!showNotifs) {
!!slug && Organization.markNotificationsSeen(slug);
setShowNotifs(true);
setHasUnseen(false);
} else {
setShowNotifs(false);
}
}
useEffect(() => {
function handleClickOutside(event: any) {
if (
bellButtonRef.current &&
bellButtonRef.current.contains(event.target)
) {
return;
}
if (
notificationRef.current &&
!notificationRef.current.contains(event.target)
) {
setShowNotifs(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
async function fetchNotifications() {
if (!slug) {
setLoading(false);
return;
}
const { notifications: _notifications } = await Organization.notifications(
slug
);
setNotifications(_notifications);
setHasUnseen(_notifications.some((notif) => notif.seen === false));
setLoading(false);
}
useEffect(() => {
if (!slug) return;
fetchNotifications();
setInterval(() => {
fetchNotifications();
}, POLLING_INTERVAL);
}, [slug]);
if (loading) return null;
return (
<div className="relative">
<button
ref={bellButtonRef}
type="button"
onClick={handleClick}
className={`group rounded-lg p-2 hover:bg-main-2 ${
showNotifs && 'bg-main-2'
}`}
>
<div className="relative">
<p
hidden={!hasUnseen}
className="absolute -top-[4px] right-0 h-[12px] w-[12px] rounded-full bg-red-600"
/>
<div className="text-sky-400">
<Bell size={24} weight={showNotifs ? 'fill' : 'bold'} />
</div>
</div>
</button>
<div
hidden={!showNotifs}
ref={notificationRef}
className="absolute right-0 top-12 z-1 max-h-[50vh] w-[20rem] overflow-y-auto rounded-lg border border-neutral-600 bg-main shadow-2xl"
>
<div className="sticky left-0 top-0 z-10 block rounded-t-lg border-b border-neutral-600 bg-main px-5 py-2 text-lg text-white shadow-lg">
Notifications
</div>
<div className="divide-y divide-gray-100">
{notifications.length === 0 ? (
<div className="flex px-4 py-3 hover:bg-main-2">
<div className="w-full pl-3 text-center">
<div className="mb-1.5 text-xs text-white">
No notifications
</div>
</div>
</div>
) : (
<div>
{notifications.map((notification) => (
<Notification
key={notification.id}
notification={notification}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}
function NotificationImage({ notification }: { notification: INotification }) {
switch (notification.symbol) {
case 'info':
return (
<div className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-white/10 p-2 text-white">
<Info size={24} weight="bold" />
</div>
);
case 'warning':
return (
<div className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-yellow-300 bg-opacity-20 p-2 text-yellow-300">
<WarningOctagon size={24} weight="bold" />
</div>
);
case 'error':
return (
<div className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-red-700/20 p-2 text-red-600">
<Warning size={24} weight="bold" />
</div>
);
case 'chroma':
return (
<div className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-white/10 p-0">
<img alt="Chroma Logo" className="rounded-full" src={ChromaLogo} />
</div>
);
case 'pinecone':
return (
<div className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-white/10 p-0">
<img
alt="Pinecone Logo"
className="rounded-full"
src={PineconeLogo}
/>
</div>
);
case 'qdrant':
return (
<div className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-white/10 p-0">
<img alt="qDrant Logo" className="rounded-full" src={qDrantLogo} />
</div>
);
case 'weaviate':
return (
<div className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-white/10 p-0">
<img
alt="Weaviate Logo"
className="rounded-full"
src={WeaviateLogo}
/>
</div>
);
default:
return (
<div className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-white/10 p-2 text-white">
<Info size={24} weight="bold" />
</div>
);
}
}
function NotificationWrapper({
notification,
children,
}: {
notification: INotification;
children: ReactNode;
}) {
if (!!notification.link) {
return (
<a
key={notification.id}
href={notification?.link || '#'}
target={notification?.target || 'self'}
className={`flex px-4 py-3 transition-all duration-300 hover:bg-main-2 ${
!notification.seen
? 'border-l-2 !border-l-sky-400 bg-sky-400/10'
: 'border-l-2 !border-l-transparent'
}`}
>
{children}
</a>
);
}
return (
<div
key={notification.id}
className={`flex px-4 py-3 hover:bg-main-2 ${
!notification.seen
? 'border-l-2 !border-l-sky-400'
: 'border-l-2 !border-l-transparent'
}`}
>
{children}
</div>
);
}
function Notification({ notification }: { notification: INotification }) {
return (
<NotificationWrapper notification={notification}>
<div className="flex flex-shrink-0 items-center justify-center">
<NotificationImage notification={notification} />
</div>
<div className="w-full pl-3">
<div className="mb-1 text-sm font-medium leading-tight text-white">
{notification.textContent}
</div>
<div className="text-sm text-white text-opacity-60">
{databaseTimestampFromNow(notification.createdAt)}
</div>
</div>
</NotificationWrapper>
);
}
// <Notification
// key={'pinecone'}
// notification={{
// id: 1,
// organization_id: 1,
// seen: false,
// textContent: 'Pinecone is now available!',
// symbol: 'pinecone',
// link: 'https://pinecone.io',
// target: '_blank',
// createdAt: '2021-10-12T12:00:00Z',
// lastUpdatedAt: '2021-10-12T12:00:00Z',
// }}
// />
// <Notification
// key={'chroma'}
// notification={{
// id: 2,
// organization_id: 1,
// seen: false,
// textContent: 'Chroma is now available!',
// symbol: 'chroma',
// link: 'https://chroma.ml',
// target: '_blank',
// createdAt: '2021-10-12T12:00:00Z',
// lastUpdatedAt: '2021-10-12T12:00:00Z',
// }}
// />
// <Notification
// key={'qdrant'}
// notification={{
// id: 3,
// organization_id: 1,
// seen: false,
// textContent: 'qDrant is now available!',
// symbol: 'qdrant',
// link: 'https://qdrant.tech',
// target: '_blank',
// createdAt: '2021-10-12T12:00:00Z',
// lastUpdatedAt: '2021-10-12T12:00:00Z',
// }}
// />
// <Notification
// key={'weaviate'}
// notification={{
// id: 4,
// organization_id: 1,
// seen: false,
// textContent: 'Weaviate is now available!',
// symbol: 'weaviate',
// link: 'https://weaviate.com',
// target: '_blank',
// createdAt: '2021-10-12T12:00:00Z',
// lastUpdatedAt: '2021-10-12T12:00:00Z',
// }}
// />
// <Notification
// key={'error'}
// notification={{
// id: 5,
// organization_id: 1,
// seen: true,
// textContent: 'Something went wrong!',
// symbol: 'error',
// link: 'https://weaviate.com',
// target: '_blank',
// createdAt: '2021-10-12T12:00:00Z',
// lastUpdatedAt: '2021-10-12T12:00:00Z',
// }}
// />
// <Notification
// key={'warning'}
// notification={{
// id: 6,
// organization_id: 1,
// seen: true,
// textContent: 'Something went wrong!',
// symbol: 'warning',
// link: 'https://weaviate.com',
// target: '_blank',
// createdAt: '2021-10-12T12:00:00Z',
// lastUpdatedAt: '2021-10-12T12:00:00Z',
// }}
// />
// <Notification
// key={'info'}
// notification={{
// id: 7,
// organization_id: 1,
// seen: false,
// textContent: 'Something went wrong!',
// symbol: 'info',
// link: 'https://weaviate.com',
// target: '_blank',
// createdAt: '2021-10-12T12:00:00Z',
// lastUpdatedAt: '2021-10-12T12:00:00Z',
// }}
// />{' '}
// <Notification
// key={'info'}
// notification={{
// id: 7,
// organization_id: 1,
// seen: true,
// textContent: 'Something went wrong!',
// symbol: 'info',
// link: 'https://weaviate.com',
// target: '_blank',
// createdAt: '2021-10-12T12:00:00Z',
// lastUpdatedAt: '2021-10-12T12:00:00Z',
// }}
// />{' '}
// <Notification
// key={'info'}
// notification={{
// id: 7,
// organization_id: 1,
// seen: false,
// textContent: 'Something went wrong!',
// symbol: 'info',
// link: 'https://weaviate.com',
// target: '_blank',
// createdAt: '2021-10-12T12:00:00Z',
// lastUpdatedAt: '2021-10-12T12:00:00Z',
// }}
// />
@@ -0,0 +1,60 @@
import { useState, useRef, useEffect } from 'react';
import useUser from '../../hooks/useUser';
import paths from '../../utils/paths';
import { STORE_TOKEN, STORE_USER } from '../../utils/constants';
export default function UserMenu() {
const { user } = useUser();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef(null);
const buttonRef = useRef();
const handleClose = (event: any) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target) &&
!buttonRef.current.contains(event.target)
) {
setShowMenu(false);
}
};
useEffect(() => {
if (showMenu) {
document.addEventListener('mousedown', handleClose);
}
return () => document.removeEventListener('mousedown', handleClose);
}, [showMenu]);
return (
<div>
<button
ref={buttonRef}
onClick={() => setShowMenu(!showMenu)}
className="flex h-[29px] w-[29px] items-center justify-center rounded-full bg-sky-400 bg-opacity-20 text-sm font-medium text-sky-400"
>
{user?.email?.slice(0, 2).toUpperCase()}
</button>
{showMenu && (
<div
ref={menuRef}
className="items-center-justify-center absolute right-0 top-12 flex w-fit rounded-lg border border-white/20 bg-main p-4"
>
<div className="flex flex-col gap-y-2">
<button
onClick={() => {
if (!window) return;
window.localStorage.removeItem(STORE_USER);
window.localStorage.removeItem(STORE_TOKEN);
window.location.replace(paths.home());
}}
type="button"
className="w-full whitespace-nowrap rounded-md px-4 py-1.5 text-left text-white hover:bg-slate-200/20"
>
Sign out
</button>
</div>
</div>
)}
</div>
);
}
+7
View File
@@ -1,6 +1,8 @@
import { ReactNode, useState } from 'react';
import Header from '../components/Header';
import Sidebar from '../components/Sidebar';
import Notifications from '../components/Notifications';
import UserMenu from '../components/UserMenu';
interface DefaultLayoutProps {
headerEntity: any;
@@ -58,6 +60,11 @@ const AppLayout = ({
/>
</div>
)}
<div className="absolute right-0 top-0 mr-9 mt-7 flex items-center gap-x-2">
<Notifications />
<UserMenu />
</div>
<main>
<div className="mx-auto rounded-tr-xl bg-main pr-6 pt-6">
{children}
+1 -1
View File
@@ -1,4 +1,4 @@
import { INotification } from '../components/Header/Notifications';
import { INotification } from '../components/Notifications';
import { API_BASE } from '../utils/constants';
import { baseHeaders, getAPIUrlString } from '../utils/request';
@@ -2,18 +2,16 @@ import { useState } from 'react';
import Organization from '../../../models/organization';
import { Loader } from 'react-feather';
import paths from '../../../utils/paths';
import { CaretDown } from '@phosphor-icons/react';
import showToast from '../../../utils/toast';
export default function OrgSettings({ organization }: { organization: any }) {
const [hasOrgChanges, setHasOrgChanges] = useState(false);
const [deleting, setDeleting] = useState(false);
const [result, setResult] = useState<{
show: boolean;
success: boolean;
error: null | string;
}>({ show: false, success: false, error: null });
const handleOrgUpdate = async (e: any) => {
e.preventDefault();
setResult({ show: false, success: false, error: null });
const form = new FormData(e.target);
const newOrgName =
(form.get('organization_name') as string) || organization?.name;
@@ -23,11 +21,20 @@ export default function OrgSettings({ organization }: { organization: any }) {
organization.slug,
data
);
setResult({ show: true, success, error });
if (success) {
showToast('Organization updated successfully, reloading...', 'success');
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
showToast(error, 'error');
}
success && setHasOrgChanges(false);
};
const handleDelete = async () => {
const handleDelete = async (e: any) => {
e.preventDefault();
if (
!confirm(
'Are you sure you want to do this. All associated information will be deleted.\n\nThis operation will ONLY remove information from Vector Admin and will not remove any data from your connected vector database.'
@@ -46,72 +53,56 @@ export default function OrgSettings({ organization }: { organization: any }) {
};
return (
<div className="col-span-12 flex-1 rounded-sm bg-white pb-6 xl:col-span-4">
<div className="flex items-start justify-between">
<div>
<h4 className="mb-6 px-7.5 text-3xl font-semibold text-black dark:text-white">
<div className="col-span-12 h-screen flex-1 rounded-sm bg-main pb-6 xl:col-span-4">
<div className="flex flex-col">
<div className="-mt-10 flex items-center gap-x-4">
<button
onClick={() => window.history.back()}
className="flex h-[34px] w-[34px] rotate-90 items-center justify-center rounded-full border border-transparent bg-zinc-900 text-white transition-all duration-300 hover:border-white/20 hover:bg-opacity-5 hover:text-white"
>
<CaretDown weight="bold" size={18} />
</button>
<div className="text-lg font-medium text-white">
Organization Settings
</h4>
</div>
</div>
</div>
<div className="px-6">
{result.show && (
<>
{result.success ? (
<p className="my-2 w-full rounded-lg border-green-800 bg-green-50 px-4 py-2 text-lg text-green-800">
Settings updated successfully.
</p>
) : (
<p className="my-2 w-full rounded-lg border-red-800 bg-red-50 px-4 py-2 text-lg text-red-800">
{result.error}
</p>
)}
</>
)}
<form
onChange={() => setHasOrgChanges(true)}
onSubmit={handleOrgUpdate}
className="border-b border-gray-200 pb-4"
>
<div className="my-4">
<label className=" block flex items-center gap-x-1 font-medium text-black dark:text-white">
Organization name
</label>
<p className="mb-2.5 text-sm text-slate-600">
<div className="ml-13">
<div className="mt-8.5 text-sm font-medium text-white">
Organization Name
</div>
<div className="mt-1 text-sm text-white text-opacity-60">
This will only change the display name of the organization.
</p>
<div className="relative flex w-1/2 items-center gap-x-4">
</div>
<div className="mt-2 flex items-center gap-x-4">
<input
type="text"
name="organization_name"
required={true}
placeholder="My Organization"
defaultValue={organization.name}
required={true}
className="w-full rounded-lg border border-stroke bg-transparent py-4 pl-6 pr-10 outline-none focus:border-primary focus-visible:shadow-none dark:border-form-strokedark dark:bg-form-input dark:focus:border-primary"
className="mt-2 inline-flex h-11 w-[210px] items-center justify-start gap-2.5 rounded-lg bg-white bg-opacity-10 p-2.5 text-sm font-medium leading-tight text-white text-opacity-60"
/>
<button
hidden={!hasOrgChanges}
type="submit"
className="rounded-lg border border-primary px-8 py-3 text-lg text-blue-500 transition-all duration-300 hover:bg-primary hover:text-white"
className="text-center text-sm font-medium text-sky-400"
>
Save
</button>
</div>
<button
onClick={handleDelete}
className="mt-5 w-fit rounded-lg p-1 text-sm leading-tight text-red-500 hover:bg-red-500 hover:text-white"
>
<Loader hidden={!deleting} size={16} className="animate-spin" />
{deleting ? 'Removing Organization' : 'Delete Organization'}
</button>
</div>
</form>
<div className="my-2 flex w-full">
<button
disabled={deleting}
onClick={handleDelete}
className="flex items-center gap-x-1 rounded-lg bg-red-100 px-4 py-2 text-red-400 hover:bg-red-600 hover:text-white disabled:cursor-wait disabled:bg-red-600 disabled:text-white"
>
<Loader hidden={!deleting} size={16} className="animate-spin" />
{deleting ? 'Removing Organization' : 'Delete Organization'}
</button>
</div>
</div>
</div>
);
File diff suppressed because it is too large Load Diff