diff --git a/backend/supabase/migrations/20240506150059_timestampz.sql b/backend/supabase/migrations/20240506150059_timestampz.sql new file mode 100644 index 000000000..0db154892 --- /dev/null +++ b/backend/supabase/migrations/20240506150059_timestampz.sql @@ -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 + + diff --git a/frontend/lib/components/Menu/components/ProfileButton/ProfileButton.module.scss b/frontend/lib/components/Menu/components/ProfileButton/ProfileButton.module.scss index e69de29bb..9c40ee285 100644 --- a/frontend/lib/components/Menu/components/ProfileButton/ProfileButton.module.scss +++ b/frontend/lib/components/Menu/components/ProfileButton/ProfileButton.module.scss @@ -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); + } + } +} diff --git a/frontend/lib/components/Menu/components/ProfileButton/ProfileButton.tsx b/frontend/lib/components/Menu/components/ProfileButton/ProfileButton.tsx index 6b7ecd1cc..20d311d04 100644 --- a/frontend/lib/components/Menu/components/ProfileButton/ProfileButton.tsx +++ b/frontend/lib/components/Menu/components/ProfileButton/ProfileButton.tsx @@ -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 ( - + { isSelected={isSelected} color="primary" /> + {remainingCredits !== null && ( +
+ {remainingCredits} + +
+ )} ); }; diff --git a/frontend/lib/components/PageHeader/Notifications/Notification/Notification.module.scss b/frontend/lib/components/PageHeader/Notifications/Notification/Notification.module.scss new file mode 100644 index 000000000..ce91bada6 --- /dev/null +++ b/frontend/lib/components/PageHeader/Notifications/Notification/Notification.module.scss @@ -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); + } +} diff --git a/frontend/lib/components/PageHeader/Notifications/Notification/Notification.tsx b/frontend/lib/components/PageHeader/Notifications/Notification/Notification.tsx new file mode 100644 index 000000000..21a7cc554 --- /dev/null +++ b/frontend/lib/components/PageHeader/Notifications/Notification/Notification.tsx @@ -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; +} + +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 ( +
+
+
+ {!notification.read &&
} + {notification.title} +
+
+ readNotif()} + /> + deleteNotif()} + /> +
+
+ + {notification.description} + + + {formatDistanceToNow(new Date(notification.datetime), { + addSuffix: true, + }).replace("about ", "")} + +
+ ); +}; + +export default Notification; diff --git a/frontend/lib/components/PageHeader/Notifications/Notifications.module.scss b/frontend/lib/components/PageHeader/Notifications/Notifications.module.scss new file mode 100644 index 000000000..554736f8d --- /dev/null +++ b/frontend/lib/components/PageHeader/Notifications/Notifications.module.scss @@ -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); + } + } +} diff --git a/frontend/lib/components/PageHeader/Notifications/Notifications.tsx b/frontend/lib/components/PageHeader/Notifications/Notifications.tsx new file mode 100644 index 000000000..b5ac64acf --- /dev/null +++ b/frontend/lib/components/PageHeader/Notifications/Notifications.tsx @@ -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([]); + const [unreadNotifications, setUnreadNotifications] = useState(0); + const [panelOpened, setPanelOpened] = useState(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 ( +
+
setPanelOpened(!panelOpened)}> + + {unreadNotifications} +
+ {panelOpened && ( +
+
+ Notifications +
+ void markAllAsRead()} + disabled={unreadNotifications === 0} + /> + | + void deleteAllNotifications()} + disabled={notifications.length === 0} + /> +
+
+ {notifications.length === 0 && ( +
+ You have no notifications +
+ )} + {notifications.map((notification, i) => ( + + ))} +
+ )} +
+ ); +}; + +export default Notifications; diff --git a/frontend/lib/components/PageHeader/Notifications/types/types.ts b/frontend/lib/components/PageHeader/Notifications/types/types.ts new file mode 100644 index 000000000..20dafe709 --- /dev/null +++ b/frontend/lib/components/PageHeader/Notifications/types/types.ts @@ -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; +} diff --git a/frontend/lib/components/PageHeader/PageHeader.module.scss b/frontend/lib/components/PageHeader/PageHeader.module.scss index 7f64f8ab6..4c379f2a0 100644 --- a/frontend/lib/components/PageHeader/PageHeader.module.scss +++ b/frontend/lib/components/PageHeader/PageHeader.module.scss @@ -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); - } - } } } diff --git a/frontend/lib/components/PageHeader/PageHeader.tsx b/frontend/lib/components/PageHeader/PageHeader.tsx index 21ee219fe..b408823bd 100644 --- a/frontend/lib/components/PageHeader/PageHeader.tsx +++ b/frontend/lib/components/PageHeader/PageHeader.tsx @@ -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 (
@@ -59,12 +50,7 @@ export const PageHeader = ({ hidden={button.hidden} /> ))} - {remainingCredits !== null && ( -
- {remainingCredits} - -
- )} + void; + disabled?: boolean; } export const TextButton = (props: TextButtonProps): JSX.Element => { - const [hovered, setHovered] = useState(false); - return (
setHovered(true)} - onMouseLeave={() => setHovered(false)} + className={`${styles.text_button_wrapper} ${ + props.disabled ? styles.disabled : "" + }`} onClick={props.onClick} > - - - {props.label} - + {!!props.iconName && ( + + )} + {props.label}
); }; diff --git a/frontend/lib/helpers/iconList.ts b/frontend/lib/helpers/iconList.ts index 7c08160df..2ef656945 100644 --- a/frontend/lib/helpers/iconList.ts +++ b/frontend/lib/helpers/iconList.ts @@ -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, diff --git a/frontend/next.config.js b/frontend/next.config.js index 1326592ab..3c2adef4a 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -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", diff --git a/frontend/styles/_Typography.module.scss b/frontend/styles/_Typography.module.scss index c5483fa1f..fbf2a312e 100644 --- a/frontend/styles/_Typography.module.scss +++ b/frontend/styles/_Typography.module.scss @@ -24,6 +24,7 @@ text-overflow: ellipsis; } +$very_tiny: 10px; $tiny: 12px; $small: 14px; $medium: 16px;