feat(frontend): add notifications for document uploads (#2549)

# Description

Please include a summary of the changes and the related issue. Please
also include relevant motivation and context.

## Checklist before requesting a review

Please delete options that are not relevant.

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented hard-to-understand areas
- [ ] I have ideally added tests that prove my fix is effective or that
my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged

## Screenshots (if appropriate):
This commit is contained in:
Antoine Dewez 2024-05-06 17:57:02 +02:00 committed by GitHub
parent 8cba448e80
commit da3880a685
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 446 additions and 63 deletions

View File

@ -0,0 +1,8 @@
alter table "public"."notifications" alter column "datetime" set default (now() AT TIME ZONE 'utc'::text);
alter table "public"."notifications" alter column "datetime" set data type timestamp with time zone using "datetime"::timestamp with time zone;
alter
publication supabase_realtime add table notifications

View File

@ -0,0 +1,19 @@
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.button_wrapper {
display: flex;
gap: Spacings.$spacing02;
align-items: center;
.credits {
display: flex;
align-items: center;
gap: Spacings.$spacing02;
.number {
font-size: Typography.$tiny;
color: var(--gold);
}
}
}

View File

@ -2,13 +2,20 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useUserApi } from "@/lib/api/user/useUserApi";
import { MenuButton } from "@/lib/components/Menu/components/MenuButton/MenuButton";
import Icon from "@/lib/components/ui/Icon/Icon";
import { useUserSettingsContext } from "@/lib/context/UserSettingsProvider/hooks/useUserSettingsContext";
import { useUserData } from "@/lib/hooks/useUserData";
import styles from "./ProfileButton.module.scss";
export const ProfileButton = (): JSX.Element => {
const pathname = usePathname() ?? "";
const isSelected = pathname.includes("/user");
const { userIdentityData } = useUserData();
const { getUserCredits } = useUserApi();
const { remainingCredits, setRemainingCredits } = useUserSettingsContext();
let username = userIdentityData?.username ?? "Profile";
@ -16,8 +23,15 @@ export const ProfileButton = (): JSX.Element => {
username = userIdentityData?.username ?? "Profile";
}, [userIdentityData]);
useEffect(() => {
void (async () => {
const res = await getUserCredits();
setRemainingCredits(res);
})();
}, []);
return (
<Link href="/user">
<Link className={styles.button_wrapper} href="/user">
<MenuButton
label={username}
iconName="user"
@ -25,6 +39,12 @@ export const ProfileButton = (): JSX.Element => {
isSelected={isSelected}
color="primary"
/>
{remainingCredits !== null && (
<div className={styles.credits}>
<span className={styles.number}>{remainingCredits}</span>
<Icon name="coin" color="gold" size="normal"></Icon>
</div>
)}
</Link>
);
};

View File

@ -0,0 +1,79 @@
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.notification_wrapper {
padding: Spacings.$spacing03;
display: flex;
flex-direction: column;
gap: Spacings.$spacing02;
width: 100%;
border-bottom: 1px solid var(--border-1);
&.no_border {
border-bottom: none;
}
&.read {
opacity: 0.4;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: Typography.$small;
overflow: hidden;
gap: Spacings.$spacing06;
.left {
display: flex;
align-items: center;
gap: Spacings.$spacing02;
overflow: hidden;
.badge {
min-width: 6px;
min-height: 6px;
border-radius: Radius.$circle;
background-color: var(--primary-0);
}
.title {
@include Typography.EllipsisOverflow;
}
}
.icons {
display: flex;
gap: Spacings.$spacing01;
align-items: center;
}
}
.description {
font-size: Typography.$tiny;
font-style: italic;
&.info {
color: var(--text-1);
}
&.warning {
color: var(--warning);
}
&.success {
color: var(--success);
}
&.error {
color: var(--dangerous);
}
}
.date {
font-size: Typography.$tiny;
color: var(--text-2);
}
}

View File

@ -0,0 +1,77 @@
import { formatDistanceToNow } from "date-fns";
import Icon from "@/lib/components/ui/Icon/Icon";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import styles from "./Notification.module.scss";
import { NotificationType } from "../types/types";
interface NotificationProps {
notification: NotificationType;
lastNotification?: boolean;
updateNotifications: () => Promise<void>;
}
export const Notification = ({
notification,
lastNotification,
updateNotifications,
}: NotificationProps): JSX.Element => {
const { supabase } = useSupabase();
const deleteNotif = async () => {
await supabase.from("notifications").delete().eq("id", notification.id);
await updateNotifications();
};
const readNotif = async () => {
await supabase
.from("notifications")
.update({ read: !notification.read })
.eq("id", notification.id);
await updateNotifications();
};
return (
<div
className={`${styles.notification_wrapper} ${
lastNotification ? styles.no_border : ""
} ${notification.read ? styles.read : ""}`}
>
<div className={styles.header}>
<div className={styles.left}>
{!notification.read && <div className={styles.badge}></div>}
<span className={styles.title}>{notification.title}</span>
</div>
<div className={styles.icons}>
<Icon
name={notification.read ? "unread" : "read"}
color="black"
handleHover={true}
size="normal"
onClick={() => readNotif()}
/>
<Icon
name="delete"
color="black"
handleHover={true}
size="normal"
onClick={() => deleteNotif()}
/>
</div>
</div>
<span className={`${styles.description} ${styles[notification.status]} `}>
{notification.description}
</span>
<span className={styles.date}>
{formatDistanceToNow(new Date(notification.datetime), {
addSuffix: true,
}).replace("about ", "")}
</span>
</div>
);
};
export default Notification;

View File

@ -0,0 +1,62 @@
@use "@/styles/BoxShadow.module.scss";
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
@use "@/styles/ZIndexes.module.scss";
.notifications_wrapper {
position: relative;
z-index: ZIndexes.$overlay;
.badge {
position: absolute;
color: var(--white-0);
background-color: var(--accent);
border-radius: Radius.$circle;
width: 14px;
height: 14px;
display: flex;
justify-content: center;
align-items: center;
font-size: Typography.$very_tiny;
top: -(Spacings.$spacing02);
right: -(Spacings.$spacing01);
}
.notifications_panel {
position: absolute;
background-color: var(--background-0);
width: 400px;
max-height: 60vh;
overflow: scroll;
right: 0;
top: Spacings.$spacing07;
border-radius: Radius.$normal;
border: 1px solid var(--border-2);
box-shadow: BoxShadow.$large;
.notifications_panel_header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-2);
font-size: Typography.$small;
padding: Spacings.$spacing03;
.title {
color: var(--text-2);
}
.buttons {
display: flex;
gap: Spacings.$spacing02;
}
}
.no_notifications {
padding: Spacings.$spacing05;
font-size: Typography.$tiny;
color: var(--text-2);
}
}
}

View File

@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { useSupabase } from "@/lib/context/SupabaseProvider";
import { Notification } from "./Notification/Notification";
import styles from "./Notifications.module.scss";
import { NotificationType } from "./types/types";
import { Icon } from "../../ui/Icon/Icon";
import { TextButton } from "../../ui/TextButton/TextButton";
export const Notifications = (): JSX.Element => {
const [notifications, setNotifications] = useState<NotificationType[]>([]);
const [unreadNotifications, setUnreadNotifications] = useState<number>(0);
const [panelOpened, setPanelOpened] = useState<boolean>(false);
const { supabase } = useSupabase();
const updateNotifications = async () => {
try {
let notifs = (await supabase.from("notifications").select()).data;
if (notifs) {
notifs = notifs.sort(
(a: NotificationType, b: NotificationType) =>
new Date(b.datetime).getTime() - new Date(a.datetime).getTime()
);
}
setNotifications(notifs ?? []);
setUnreadNotifications(
notifs?.filter((n: NotificationType) => !n.read).length ?? 0
);
} catch (error) {
console.error(error);
}
};
const deleteAllNotifications = async () => {
for (const notification of notifications) {
await supabase.from("notifications").delete().eq("id", notification.id);
}
await updateNotifications();
};
const markAllAsRead = async () => {
for (const notification of notifications) {
await supabase
.from("notifications")
.update({ read: !notification.read })
.eq("id", notification.id);
}
await updateNotifications();
};
useEffect(() => {
const channel = supabase
.channel("notifications")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "notifications" },
() => {
void updateNotifications();
}
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, []);
useEffect(() => {
void (async () => {
await updateNotifications();
})();
}, []);
return (
<div className={styles.notifications_wrapper}>
<div onClick={() => setPanelOpened(!panelOpened)}>
<Icon
name="notifications"
size="large"
color="black"
handleHover={true}
/>
<span className={styles.badge}>{unreadNotifications}</span>
</div>
{panelOpened && (
<div className={styles.notifications_panel}>
<div className={styles.notifications_panel_header}>
<span className={styles.title}>Notifications</span>
<div className={styles.buttons}>
<TextButton
label="Mark all as read"
color="black"
onClick={() => void markAllAsRead()}
disabled={unreadNotifications === 0}
/>
<span>|</span>
<TextButton
label="Delete all"
color="black"
onClick={() => void deleteAllNotifications()}
disabled={notifications.length === 0}
/>
</div>
</div>
{notifications.length === 0 && (
<div className={styles.no_notifications}>
You have no notifications
</div>
)}
{notifications.map((notification, i) => (
<Notification
key={i}
notification={notification}
lastNotification={i === notifications.length - 1}
updateNotifications={updateNotifications}
/>
))}
</div>
)}
</div>
);
};
export default Notifications;

View File

@ -0,0 +1,16 @@
export enum NotificationStatus {
Info = "info",
Warning = "warning",
Error = "error",
Success = "success",
}
export interface NotificationType {
id: string;
title: string;
datetime: string;
status: NotificationStatus;
archived: boolean;
read: boolean;
description: string;
}

View File

@ -32,16 +32,5 @@
display: flex;
gap: Spacings.$spacing04;
align-items: center;
.credits {
display: flex;
align-items: center;
gap: Spacings.$spacing02;
.number {
font-size: Typography.$tiny;
color: var(--gold);
}
}
}
}

View File

@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { useUserApi } from "@/lib/api/user/useUserApi";
import { useMenuContext } from "@/lib/context/MenuProvider/hooks/useMenuContext";
import { useUserSettingsContext } from "@/lib/context/UserSettingsProvider/hooks/useUserSettingsContext";
import { ButtonType } from "@/lib/types/QuivrButton";
import { Notifications } from "./Notifications/Notifications";
import styles from "./PageHeader.module.scss";
import { Icon } from "../ui/Icon/Icon";
@ -24,8 +24,6 @@ export const PageHeader = ({
const { isOpened } = useMenuContext();
const { isDarkMode, setIsDarkMode } = useUserSettingsContext();
const [lightModeIconName, setLightModeIconName] = useState("sun");
const { remainingCredits, setRemainingCredits } = useUserSettingsContext();
const { getUserCredits } = useUserApi();
const toggleTheme = () => {
setIsDarkMode(!isDarkMode);
@ -35,13 +33,6 @@ export const PageHeader = ({
setLightModeIconName(isDarkMode ? "sun" : "moon");
}, [isDarkMode]);
useEffect(() => {
void (async () => {
const res = await getUserCredits();
setRemainingCredits(res);
})();
}, []);
return (
<div className={styles.page_header_wrapper}>
<div className={`${styles.left} ${!isOpened ? styles.menu_closed : ""}`}>
@ -59,12 +50,7 @@ export const PageHeader = ({
hidden={button.hidden}
/>
))}
{remainingCredits !== null && (
<div className={styles.credits}>
<span className={styles.number}>{remainingCredits}</span>
<Icon name="coin" color="gold" size="normal"></Icon>
</div>
)}
<Notifications />
<Icon
name={lightModeIconName}
color="black"

View File

@ -6,20 +6,25 @@
gap: Spacings.$spacing03;
cursor: pointer;
background-color: transparent;
}
.black {
color: var(--text-3);
&.disabled {
pointer-events: none;
opacity: 0.5;
}
&.hovered {
color: var(--primary-0);
}
}
.dangerous {
color: var(--dangerous);
&.hovered {
color: var(--dangerous)-dark;
.black {
color: var(--text-3);
&:hover {
color: var(--primary-0);
}
}
.dangerous {
color: var(--dangerous);
&:hover {
color: var(--dangerous-0);
}
}
}

View File

@ -1,5 +1,3 @@
import { useState } from "react";
import { iconList } from "@/lib/helpers/iconList";
import { Color } from "@/lib/types/Colors";
@ -8,36 +6,25 @@ import styles from "./TextButton.module.scss";
import { Icon } from "../Icon/Icon";
interface TextButtonProps {
iconName: keyof typeof iconList;
iconName?: keyof typeof iconList;
label: string;
color: Color;
onClick?: () => void;
disabled?: boolean;
}
export const TextButton = (props: TextButtonProps): JSX.Element => {
const [hovered, setHovered] = useState(false);
return (
<div
className={styles.text_button_wrapper}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`${styles.text_button_wrapper} ${
props.disabled ? styles.disabled : ""
}`}
onClick={props.onClick}
>
<Icon
name={props.iconName}
size="normal"
color={props.color}
hovered={hovered}
/>
<span
className={`
${styles[props.color] ?? ""}
${hovered ? styles.hovered ?? "" : ""}
`}
>
{props.label}
</span>
{!!props.iconName && (
<Icon name={props.iconName} size="normal" color={props.color} />
)}
<span className={styles[props.color]}>{props.label}</span>
</div>
);
};

View File

@ -33,6 +33,7 @@ import { HiBuildingOffice } from "react-icons/hi2";
import {
IoIosAdd,
IoIosHelpCircleOutline,
IoIosNotifications,
IoIosRadio,
IoMdClose,
IoMdLogOut,
@ -66,6 +67,8 @@ import {
MdDynamicFeed,
MdHistory,
MdLink,
MdMarkEmailRead,
MdMarkEmailUnread,
MdOutlineModeEditOutline,
MdUnfoldLess,
MdUnfoldMore,
@ -121,12 +124,14 @@ export const iconList: { [name: string]: IconType } = {
loader: AiOutlineLoading3Quarters,
logout: IoMdLogOut,
moon: FaMoon,
notifications: IoIosNotifications,
office: HiBuildingOffice,
options: SlOptions,
paragraph: BsTextParagraph,
prompt: FaRegKeyboard,
redirection: BsArrowRightShort,
radio: IoIosRadio,
read: MdMarkEmailRead,
robot: LiaRobotSolid,
search: LuSearch,
settings: IoSettingsSharp,
@ -140,6 +145,7 @@ export const iconList: { [name: string]: IconType } = {
twitter: FaTwitter,
unfold: MdUnfoldMore,
unlock: FaUnlock,
unread: MdMarkEmailUnread,
upload: FiUpload,
uploadFile: MdUploadFile,
user: FaRegUserCircle,

View File

@ -60,6 +60,8 @@ const ContentSecurityPolicy = {
"connect-src": [
"'self'",
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_URL.replace("https", "wss"),
process.env.NEXT_PUBLIC_SUPABASE_URL.replace("http", "ws"),
process.env.NEXT_PUBLIC_BACKEND_URL,
process.env.NEXT_PUBLIC_CMS_URL,
"*.intercom.io",

View File

@ -24,6 +24,7 @@
text-overflow: ellipsis;
}
$very_tiny: 10px;
$tiny: 12px;
$small: 14px;
$medium: 16px;