From b3ff2a59beef8739a41f9a8a61a78ab001655218 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc <nicotemciuc@gmail.com> Date: Thu, 14 Nov 2024 11:59:39 -0300 Subject: [PATCH 1/6] build(deps): add `switch` component --- front-office/package.json | 1 + front-office/pnpm-lock.yaml | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/front-office/package.json b/front-office/package.json index 1890603..a99dc61 100644 --- a/front-office/package.json +++ b/front-office/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", diff --git a/front-office/pnpm-lock.yaml b/front-office/pnpm-lock.yaml index 9d88fce..f7554e2 100644 --- a/front-office/pnpm-lock.yaml +++ b/front-office/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.12)(react@19.0.0-rc-02c0e824-20241028) + '@radix-ui/react-switch': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028) '@radix-ui/react-toast': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028) @@ -641,6 +644,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-switch@1.1.1': + resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-toast@1.2.2': resolution: {integrity: sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==} peerDependencies: @@ -2724,6 +2740,21 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-switch@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@19.0.0-rc-02c0e824-20241028) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@19.0.0-rc-02c0e824-20241028) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@19.0.0-rc-02c0e824-20241028) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.12)(react@19.0.0-rc-02c0e824-20241028) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.12)(react@19.0.0-rc-02c0e824-20241028) + react: 19.0.0-rc-02c0e824-20241028 + react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-toast@1.2.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028)': dependencies: '@radix-ui/primitive': 1.1.0 -- GitLab From 8784d7cb74eddfa177ed7be13c2eb0737adfe790 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc <nicotemciuc@gmail.com> Date: Thu, 14 Nov 2024 12:00:20 -0300 Subject: [PATCH 2/6] refactor: remove categories and search bar from navbar --- front-office/src/components/navbar.tsx | 44 -------------------------- 1 file changed, 44 deletions(-) diff --git a/front-office/src/components/navbar.tsx b/front-office/src/components/navbar.tsx index aa0ea6b..57b8c14 100644 --- a/front-office/src/components/navbar.tsx +++ b/front-office/src/components/navbar.tsx @@ -13,51 +13,7 @@ const Navbar = () => { <span className="text-lg font-semibold">Verificando.uy</span> </Link> <nav className="hidden items-center gap-4 md:flex"> - <Link href="#" className="hover:underline" > - Home - </Link> - <Link href="#" className="hover:underline" > - Politica - </Link> - <Link href="#" className="hover:underline" > - EconomÃa - </Link> - <Link href="#" className="hover:underline" > - Sociedad - </Link> - <Link href="#" className="hover:underline" > - Ciencia - </Link> - <Link href="#" className="hover:underline" > - TecnologÃa - </Link> - <Link href="#" className="hover:underline" > - Deporte - </Link> - <Link href="#" className="hover:underline" > - Cultura - </Link> - <Link href="#" className="hover:underline" > - Salud - </Link> - <Link href="#" className="hover:underline" > - Ambiente - </Link> - <Link href="#" className="hover:underline" > - Policiales - </Link> - <Link href="#" className="hover:underline" > - Otros - </Link> </nav> - <div className="relative"> - <SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" /> - <Input - type="search" - placeholder="Buscar noticias..." - className="w-full rounded-md bg-gray-800 px-8 py-2 text-sm focus:outline-none" - /> - </div> <UserMenu /> </header> ); -- GitLab From 91aebe28443b84b3e4171351efaa11b7f2c73ccf Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc <nicotemciuc@gmail.com> Date: Thu, 14 Nov 2024 12:00:52 -0300 Subject: [PATCH 3/6] chore: add `switch` component --- front-office/src/components/ui/switch.tsx | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 front-office/src/components/ui/switch.tsx diff --git a/front-office/src/components/ui/switch.tsx b/front-office/src/components/ui/switch.tsx new file mode 100644 index 0000000..5f4117f --- /dev/null +++ b/front-office/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef<typeof SwitchPrimitives.Root>, + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> +>(({ className, ...props }, ref) => ( + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className + )} + {...props} + ref={ref} + > + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" + )} + /> + </SwitchPrimitives.Root> +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } -- GitLab From a2ebe393b9692e34f6b3f727e36fcaccbaaa9cff Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc <nicotemciuc@gmail.com> Date: Thu, 14 Nov 2024 12:01:25 -0300 Subject: [PATCH 4/6] chore: add manage my own notifications --- .../components/custom/manage-notification.tsx | 105 +++++++++++++++ .../components/custom/notification-button.tsx | 124 ++++++++++++++++++ .../src/components/custom/user-menu.tsx | 30 ++++- 3 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 front-office/src/components/custom/manage-notification.tsx create mode 100644 front-office/src/components/custom/notification-button.tsx diff --git a/front-office/src/components/custom/manage-notification.tsx b/front-office/src/components/custom/manage-notification.tsx new file mode 100644 index 0000000..d84465f --- /dev/null +++ b/front-office/src/components/custom/manage-notification.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from "react"; +import Cookies from "js-cookie"; +import axiosInstance from "@/utils/axios-instance"; +import { Switch } from "@/components/ui/switch"; +import { jwtDecode } from "jwt-decode"; + +const ManageNotification = ({ setIsNotificationOpen }) => { + const [categories, setCategories] = useState([]); + const [subscriptions, setSubscriptions] = useState(new Set()); + const [loadingCategories, setLoadingCategories] = useState(true); + const [loadingSubscriptions, setLoadingSubscriptions] = useState(true); + + useEffect(() => { + async function fetchCategoriesAndSubscriptions() { + try { + const token = Cookies.get('token'); + + // Fetch all categories + const categoriesResponse = await axiosInstance.get(`/facts/categories`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + setCategories(categoriesResponse); + setLoadingCategories(false); + + const userId = getUserIdFromToken(token); // Replace with actual extraction logic + const subscriptionsResponse = await axiosInstance.get(`/subscriptions/${userId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + console.log("subscriptionsResponse", subscriptionsResponse); + + const subscribedCategories = new Set(subscriptionsResponse.map((sub) => sub.category)); + setSubscriptions(subscribedCategories); + setLoadingSubscriptions(false); + + } catch (error) { + console.error("Failed to fetch categories or subscriptions:", error); + setLoadingCategories(true); + setLoadingSubscriptions(true); + } + } + + fetchCategoriesAndSubscriptions(); + }, []); + + const handleToggleSubscription = async (category) => { + const token = Cookies.get('token'); + const userId = getUserIdFromToken(token); + + if (subscriptions.has(category)) { + await axiosInstance.delete(`/subscriptions/${userId}/${category}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + setSubscriptions((prev) => { + const newSubscriptions = new Set(prev); + newSubscriptions.delete(category); + return newSubscriptions; + }); + } else { + await axiosInstance.post(`/subscriptions`, { + userId, + category, + }, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + setSubscriptions((prev) => new Set(prev).add(category)); + } + }; + + if (loadingCategories || loadingSubscriptions) { + return <div>Loading...</div>; + } + + return ( + <div className="max-w-full grid grid-cols-2 gap-4"> + {categories.map((category) => ( + <div key={category} className="flex items-center justify-between rounded-lg border p-4 my-4"> + <div className="space-y-0.5"> + <div className="font-semibold">{category}</div> + </div> + <div> + <Switch + checked={subscriptions.has(category)} + onClick={() => handleToggleSubscription(category)} + /> + </div> + </div> + ))} + </div> + ); +}; + +function getUserIdFromToken(token) { + const { id } = jwtDecode(token); + return id; +} + +export default ManageNotification; diff --git a/front-office/src/components/custom/notification-button.tsx b/front-office/src/components/custom/notification-button.tsx new file mode 100644 index 0000000..6687d31 --- /dev/null +++ b/front-office/src/components/custom/notification-button.tsx @@ -0,0 +1,124 @@ +import { useEffect, useState } from 'react'; +import { Bell, X } from 'lucide-react'; +import { jwtDecode } from "jwt-decode"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import axiosInstance from '@/utils/axios-instance'; +import Cookies from 'js-cookie'; + +const NotificationsDropdown = () => { + const [notifications, setNotifications] = useState([]); + const [subscriptions, setSubscriptions] = useState([]); + const [webSocket, setWebSocket] = useState(null); + + const token = Cookies.get('token'); + const { id } = jwtDecode(token); + const userId = id; + + const loadSubscriptions = async () => { + try { + const response = await axiosInstance.get(`/subscriptions/${userId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + setSubscriptions(response.data); + } catch (error) { + console.error('Error loading subscriptions:', error); + } + }; + + // Function to unsubscribe from a category + const unsubscribeFromCategory = async (category) => { + try { + await axiosInstance.delete(`/subscriptions/${userId}/${category}`, { + headers: { Authorization: `Bearer ${token}` } + }); + loadSubscriptions(); + } catch (error) { + console.error('Error unsubscribing from category:', error); + } + }; + + // WebSocket setup for receiving notifications + useEffect(() => { + const socket = new WebSocket(`ws://localhost:8080/notifications/${userId}`); + setWebSocket(socket); + + socket.onmessage = (event) => { + const data = JSON.parse(event.data); + setNotifications((prev) => [{ message: data.notification, read: false }, ...prev]); + }; + + socket.onopen = () => { + console.log('WebSocket connected'); + // Heartbeat to keep the connection alive every 40 seconds + const heartbeat = setInterval(() => { + socket.send(JSON.stringify({ type: 'heartbeat' })); + }, 40000); + + // Clear interval on WebSocket close + socket.onclose = () => clearInterval(heartbeat); + }; + + return () => socket.close(); // Cleanup on component unmount + }, [userId]); + + // Clear a single notification + const clearNotification = (index) => { + setNotifications(notifications.filter((_, i) => i !== index)); + }; + + // Clear all notifications + const clearAllNotifications = () => setNotifications([]); + + return ( + <DropdownMenu> + <DropdownMenuTrigger className="flex items-center p-2 text-gray-500 hover:text-gray-800 focus:outline-none"> + <Bell className="w-6 h-6" /> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-64 p-2 bg-white border rounded-md shadow-lg"> + {notifications.length > 0 ? ( + notifications.map((notification, index) => ( + <DropdownMenuItem + key={index} + className={`flex items-center justify-between p-2 rounded-md ${ + notification.read ? 'text-gray-400' : 'text-black font-semibold' + }`} + > + <span>{notification.message}</span> + <button + onClick={(e) => { + e.stopPropagation(); + clearNotification(index); + }} + className="p-1 text-gray-400 hover:text-red-500" + > + <X className="w-4 h-4" /> + </button> + </DropdownMenuItem> + )) + ) : ( + <p className="p-2 text-gray-500 text-center">No new notifications</p> + )} + {notifications.length > 0 && ( + <> + <DropdownMenuSeparator className="my-2" /> + <DropdownMenuItem + onSelect={clearAllNotifications} + className="p-2 text-red-500 cursor-pointer hover:bg-red-50 rounded-md" + > + Clear All Notifications + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> + ); +}; + +export default NotificationsDropdown; diff --git a/front-office/src/components/custom/user-menu.tsx b/front-office/src/components/custom/user-menu.tsx index 7339f0b..8f1c4ba 100644 --- a/front-office/src/components/custom/user-menu.tsx +++ b/front-office/src/components/custom/user-menu.tsx @@ -4,9 +4,11 @@ import Cookies from "js-cookie"; import { useEffect, useState } from "react"; import { jwtDecode } from "jwt-decode"; import LoginButton from "./login-button"; -import { LogOut } from "lucide-react"; +import { LogOut, Bolt } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import DonateButton from '@/components/custom/donate-button' +import NotificationButton from '@/components/custom/notification-button' +import ManageNotification from '@/components/custom/manage-notification' import { Button } from '@/components/ui/button' import { Dialog, @@ -34,6 +36,7 @@ import { useRouter } from "next/navigation"; const UserMenu = () => { const [user, setUser] = useState(null); const [isOpen, setIsOpen] = useState(false); + const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); const logout = () => { Cookies.remove("token"); @@ -49,10 +52,11 @@ const UserMenu = () => { }, []); return ( - <> + <div className="flex items-center space-x-4"> {user ? ( <> <DonateButton /> + <NotificationButton /> <Dialog open={isOpen} onOpenChange={setIsOpen}> {} <DialogContent> <DialogHeader> @@ -64,6 +68,17 @@ const UserMenu = () => { <FactForm setIsOpen={setIsOpen} /> {} </DialogContent> </Dialog> + <Dialog open={isNotificationsOpen} onOpenChange={setIsNotificationsOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>Manage Notifications</DialogTitle> + <DialogDescription> + You can manage your notifications here. + </DialogDescription> + </DialogHeader> + <ManageNotification setIsOpen={setIsNotificationsOpen} /> + </DialogContent> + </Dialog> <DropdownMenu> <DropdownMenuTrigger asChild> @@ -92,6 +107,15 @@ const UserMenu = () => { Suggest a Fact </button> </DropdownMenuItem> + <DropdownMenuItem> + <button + onClick={() => setIsNotificationsOpen(true)} + className="flex justify-start rounded-md transition-all duration-75 hover:bg-neutral-100" + > + <Bolt size={24} className="mr-2" /> + Manage Notifications + </button> + </DropdownMenuItem> <DropdownMenuItem onClick={logout}> <button className="flex justify-start rounded-md transition-all duration-75 hover:bg-neutral-100 text-red-500 hover:text-red-900"> <LogOut size={24} className="mr-2" /> @@ -104,7 +128,7 @@ const UserMenu = () => { ) : ( <LoginButton /> )} - </> + </div> ); } -- GitLab From 3ebde4829566df2c30d3eb069e32e0afc568c0c5 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc <nicotemciuc@gmail.com> Date: Thu, 14 Nov 2024 12:22:34 -0300 Subject: [PATCH 5/6] chore: notifications works --- .../components/custom/notification-button.tsx | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/front-office/src/components/custom/notification-button.tsx b/front-office/src/components/custom/notification-button.tsx index 6687d31..a45c960 100644 --- a/front-office/src/components/custom/notification-button.tsx +++ b/front-office/src/components/custom/notification-button.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Bell, X } from 'lucide-react'; +import { Bell, BellRing, X } from 'lucide-react'; import { jwtDecode } from "jwt-decode"; import { DropdownMenu, @@ -14,35 +14,12 @@ import Cookies from 'js-cookie'; const NotificationsDropdown = () => { const [notifications, setNotifications] = useState([]); - const [subscriptions, setSubscriptions] = useState([]); const [webSocket, setWebSocket] = useState(null); const token = Cookies.get('token'); const { id } = jwtDecode(token); const userId = id; - const loadSubscriptions = async () => { - try { - const response = await axiosInstance.get(`/subscriptions/${userId}`, { - headers: { Authorization: `Bearer ${token}` } - }); - setSubscriptions(response.data); - } catch (error) { - console.error('Error loading subscriptions:', error); - } - }; - - // Function to unsubscribe from a category - const unsubscribeFromCategory = async (category) => { - try { - await axiosInstance.delete(`/subscriptions/${userId}/${category}`, { - headers: { Authorization: `Bearer ${token}` } - }); - loadSubscriptions(); - } catch (error) { - console.error('Error unsubscribing from category:', error); - } - }; // WebSocket setup for receiving notifications useEffect(() => { @@ -51,6 +28,7 @@ const NotificationsDropdown = () => { socket.onmessage = (event) => { const data = JSON.parse(event.data); + console.log('Received notification:', data); setNotifications((prev) => [{ message: data.notification, read: false }, ...prev]); }; -- GitLab From d9877553caf7db65c123a152281e4572ccd6e268 Mon Sep 17 00:00:00 2001 From: Nicolas Temciuc <nicotemciuc@gmail.com> Date: Thu, 14 Nov 2024 12:25:39 -0300 Subject: [PATCH 6/6] chore: change icon when new notification --- .../components/custom/notification-button.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/front-office/src/components/custom/notification-button.tsx b/front-office/src/components/custom/notification-button.tsx index a45c960..ac06d97 100644 --- a/front-office/src/components/custom/notification-button.tsx +++ b/front-office/src/components/custom/notification-button.tsx @@ -8,7 +8,7 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +} from "@/components/ui/dropdown-menu"; import axiosInstance from '@/utils/axios-instance'; import Cookies from 'js-cookie'; @@ -20,7 +20,6 @@ const NotificationsDropdown = () => { const { id } = jwtDecode(token); const userId = id; - // WebSocket setup for receiving notifications useEffect(() => { const socket = new WebSocket(`ws://localhost:8080/notifications/${userId}`); @@ -28,12 +27,11 @@ const NotificationsDropdown = () => { socket.onmessage = (event) => { const data = JSON.parse(event.data); - console.log('Received notification:', data); + if (data.notification.includes('Connection established')) return; setNotifications((prev) => [{ message: data.notification, read: false }, ...prev]); }; socket.onopen = () => { - console.log('WebSocket connected'); // Heartbeat to keep the connection alive every 40 seconds const heartbeat = setInterval(() => { socket.send(JSON.stringify({ type: 'heartbeat' })); @@ -54,10 +52,22 @@ const NotificationsDropdown = () => { // Clear all notifications const clearAllNotifications = () => setNotifications([]); + // Count unread notifications + const unreadCount = notifications.filter((notification) => !notification.read).length; + return ( <DropdownMenu> - <DropdownMenuTrigger className="flex items-center p-2 text-gray-500 hover:text-gray-800 focus:outline-none"> - <Bell className="w-6 h-6" /> + <DropdownMenuTrigger className="relative flex items-center p-2 text-gray-500 hover:text-gray-800 focus:outline-none"> + {unreadCount > 0 ? ( + <BellRing className="w-6 h-6 text-red-500" /> + ) : ( + <Bell className="w-6 h-6" /> + )} + {unreadCount > 0 && ( + <span className="absolute top-0 right-0 flex items-center justify-center w-4 h-4 text-xs font-semibold text-white bg-red-500 rounded-full"> + {unreadCount} + </span> + )} </DropdownMenuTrigger> <DropdownMenuContent className="w-64 p-2 bg-white border rounded-md shadow-lg"> {notifications.length > 0 ? ( -- GitLab