Merge branch 'fe/feat/notification-popover' into 'fe/develop'

Feat: 알림 컴포넌트 추가 - S11P21S002-227

See merge request s11-s-project/S11P21S002!233
This commit is contained in:
정현조 2024-09-29 20:31:18 +09:00
commit 557be93670
17 changed files with 370 additions and 45 deletions

View File

@ -12,6 +12,7 @@
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
@ -4296,6 +4297,43 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz",
"integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.0",
"@radix-ui/react-focus-guards": "1.1.0",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.0",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.7"
},
"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
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",

View File

@ -18,6 +18,7 @@
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",

View File

@ -0,0 +1,22 @@
import api from '@/api/axiosConfig';
import { AlarmResponse } from '@/types';
export async function getAlarmList() {
return api.get<AlarmResponse[]>('/alarm').then(({ data }) => data);
}
export async function createAlarmTest() {
return api.post('/alarm/test').then(({ data }) => data);
}
export async function readAlarm(alarmId: number) {
return api.put(`/alarm/${alarmId}`).then(({ data }) => data);
}
export async function deleteAlarm(alarmId: number) {
return api.delete(`/alarm/${alarmId}`).then(({ data }) => data);
}
export async function deleteAllAlarm() {
return api.delete('/alarm').then(({ data }) => data);
}

View File

@ -14,7 +14,7 @@ export async function logout() {
return api.post('/auth/logout').then(({ data }) => data); return api.post('/auth/logout').then(({ data }) => data);
} }
export async function saveFcmToken() { export async function getAndSaveFcmToken() {
const fcmToken = await getFcmToken(); const fcmToken = await getFcmToken();
return api.post('/auth/fcm', { token: fcmToken }).then(({ data }) => ({ data, fcmToken })); return api.post('/auth/fcm', { token: fcmToken }).then(({ data }) => ({ data, fcmToken }));
} }

View File

@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { onMessage } from 'firebase/messaging';
import { messaging } from '@/api/firebaseConfig';
import useFcmTokenQuery from '@/queries/auth/useFcmTokenQuery';
import useGetAlarmListQuery from '@/queries/alarms/useGetAlarmListQuery';
import useResetAlarmListQuery from '@/queries/alarms/useResetAlarmListQuery';
import useCreateAlarmTestQuery from '@/queries/alarms/useCreateAlarmTestQuery';
import useReadAlarmQuery from '@/queries/alarms/useReadAlarmQuery';
import useDeleteAlarmQuery from '@/queries/alarms/useDeleteAlarmQuery';
import useDeleteAllAlarmQuery from '@/queries/alarms/useDeleteAllAlarmQuery';
import { Bell, Mail, MailOpen, Trash2 } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
export default function AlarmPopover() {
const [unread, setUnread] = useState<boolean>(false);
const timeAgo = (date: string | Date) => {
const now = new Date();
const past = new Date(date);
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
if (diffInSeconds < 60) return `${Math.max(diffInSeconds, 0)}초 전`;
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) return `${diffInMinutes}분 전`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}시간 전`;
const diffInDays = Math.floor(diffInHours / 24);
return `${diffInDays}일 전`;
};
const resetAlarmList = useResetAlarmListQuery();
const createAlarmTest = useCreateAlarmTestQuery();
const readAlarm = useReadAlarmQuery();
const deleteAlarm = useDeleteAlarmQuery();
const deleteAllAlarm = useDeleteAllAlarmQuery();
const handleResetAlarmList = () => {
resetAlarmList.mutate();
};
const handleCreateAlarmTest = () => {
createAlarmTest.mutate();
};
const handleReadAlarm = (alarmId: number) => {
readAlarm.mutate(alarmId);
};
const handleDeleteAlarm = (alarmId: number) => {
deleteAlarm.mutate(alarmId);
};
const handleDeleteAllAlarm = () => {
deleteAllAlarm.mutate();
};
useFcmTokenQuery();
const { data: alarms } = useGetAlarmListQuery();
onMessage(messaging, (payload) => {
if (!payload.data) return;
console.log('new message arrived');
handleResetAlarmList();
});
useEffect(() => {
const unreadCnt = alarms.filter((alarm) => !alarm.isRead).length;
if (unreadCnt > 0) {
setUnread(true);
} else {
setUnread(false);
}
}, [alarms]);
return (
<Popover>
<PopoverTrigger asChild>
<button
className="flex items-center justify-center p-2"
onClick={() => {}}
>
<Bell className="h-4 w-4 cursor-pointer text-black sm:h-5 sm:w-5" />
<div className={cn('mt-[14px] h-1.5 w-1.5 rounded-full', unread ? 'bg-blue-500' : 'bg-transparent')}></div>
</button>
</PopoverTrigger>
<PopoverContent
className="w-80 overflow-hidden rounded-lg p-0"
align="end"
sideOffset={14}
alignOffset={0}
>
<div className="flex w-full items-center px-[18px] py-3">
<h2 className="body-strong flex-1"></h2>
<button
className="body-small p-1 text-blue-500"
onClick={handleCreateAlarmTest}
>
</button>
{unread ? (
<button
className="body-small p-1"
onClick={() => {}}
>
</button>
) : (
<button
className="body-small p-1"
onClick={() => {}}
>
</button>
)}
<button
className="body-small p-1 text-red-500"
onClick={handleDeleteAllAlarm}
>
</button>
</div>
<hr />
{alarms.length == 0 && (
<div className="flex w-full items-center px-[18px] py-3 duration-150">
<p className="body-small text-gray-500"> .</p>
</div>
)}
{alarms
.slice()
.reverse()
.map((alarm) => (
<div
key={alarm.id}
className="flex w-full items-center bg-white py-2 pr-[18px] duration-150 hover:bg-gray-200"
>
<div
className={cn('mx-1.5 h-1.5 w-1.5 rounded-full', alarm.isRead ? 'bg-transparent' : 'bg-blue-500')}
></div>
<div className="flex flex-1 flex-col">
<p className="body-small">
[{alarm.id}] {alarm.type} .
</p>
<p className="caption text-gray-500">{timeAgo(alarm.createdAt)}</p>
</div>
{alarm.isRead ? (
<button
className="p-1"
onClick={() => {}}
>
<MailOpen
size={16}
className="stroke-gray-400"
/>
</button>
) : (
<button
className="p-1"
onClick={() => {
handleReadAlarm(alarm.id);
}}
>
<Mail size={16} />
</button>
)}
<button
className="p-1"
onClick={() => {
handleDeleteAlarm(alarm.id);
}}
>
<Trash2
size={16}
className="stroke-red-500"
/>
</button>
</div>
))}
</PopoverContent>
</Popover>
);
}

View File

@ -1,10 +1,10 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Bell } from 'lucide-react';
import { useLocation, Link } from 'react-router-dom'; import { useLocation, Link } from 'react-router-dom';
import UserProfileModal from './UserProfileModal'; import UserProfileModal from './UserProfileModal';
import WorkspaceNavigation from './WorkspaceNavigation'; import WorkspaceNavigation from './WorkspaceNavigation';
import useAuthStore from '@/stores/useAuthStore'; import useAuthStore from '@/stores/useAuthStore';
import { Suspense } from 'react'; import { Suspense } from 'react';
import AlarmPopover from './AlarmPopover';
export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {} export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
@ -39,8 +39,8 @@ export default function Header({ className, ...props }: HeaderProps) {
</div> </div>
{!isHomePage && profile && ( {!isHomePage && profile && (
<div className="flex items-center gap-4 md:gap-5"> <div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-black sm:h-5 sm:w-5" /> <AlarmPopover />
<UserProfileModal /> <UserProfileModal />
</div> </div>
)} )}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-gray-200 bg-white p-4 text-gray-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-50",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -1,31 +0,0 @@
import { createFcmNotification, saveFcmToken } from '@/api/authApi';
import { handleForegroundMessages } from '@/api/firebaseConfig';
import { Button } from '@/components/ui/button';
export default function FirebaseTest() {
const handleSaveFcmToken = async () => {
await saveFcmToken();
};
const handleCreateNotification = async () => {
await createFcmNotification();
};
handleSaveFcmToken();
handleForegroundMessages();
return (
<div>
<h1 className="heading p-2">hello, firebase!</h1>
<div className="p-2">
<Button
onClick={handleCreateNotification}
variant="outlinePrimary"
className="mr-2"
>
FCM
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { createAlarmTest } from '@/api/alarmApi';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export default function useCreateAlarmTestQuery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createAlarmTest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alarmList'] });
},
});
}

View File

@ -0,0 +1,13 @@
import { deleteAlarm } from '@/api/alarmApi';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export default function useDeleteAlarmQuery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (alarmId: number) => deleteAlarm(alarmId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alarmList'] });
},
});
}

View File

@ -0,0 +1,13 @@
import { deleteAllAlarm } from '@/api/alarmApi';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export default function useDeleteAllAlarmQuery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteAllAlarm,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alarmList'] });
},
});
}

View File

@ -0,0 +1,9 @@
import { getAlarmList } from '@/api/alarmApi';
import { useSuspenseQuery } from '@tanstack/react-query';
export default function useGetAlarmListQuery() {
return useSuspenseQuery({
queryKey: ['alarmList'],
queryFn: getAlarmList,
});
}

View File

@ -0,0 +1,13 @@
import { readAlarm } from '@/api/alarmApi';
import { useMutation, useQueryClient } from '@tanstack/react-query';
export default function useReadAlarmQuery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (alarmId: number) => readAlarm(alarmId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alarmList'] });
},
});
}

View File

@ -0,0 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
export default function useUpdateAlarmQuery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alarmList'] });
},
});
}

View File

@ -0,0 +1,9 @@
import { getAndSaveFcmToken } from '@/api/authApi';
import { useSuspenseQuery } from '@tanstack/react-query';
export default function useFcmTokenQuery() {
return useSuspenseQuery({
queryKey: ['fcmToken'],
queryFn: getAndSaveFcmToken,
});
}

View File

@ -20,7 +20,6 @@ import NotFound from '@/pages/NotFound';
import ReviewRequest from '@/pages/ReviewRequest'; import ReviewRequest from '@/pages/ReviewRequest';
import ModelIndex from '@/pages/ModelIndex'; import ModelIndex from '@/pages/ModelIndex';
import ModelDetail from '@/pages/ModelDetail'; import ModelDetail from '@/pages/ModelDetail';
import FirebaseTest from '@/pages/FirebaseTest';
export const webPath = { export const webPath = {
home: () => '/', home: () => '/',
@ -28,7 +27,6 @@ export const webPath = {
workspace: () => '/workspace', workspace: () => '/workspace',
admin: () => `/admin`, admin: () => `/admin`,
oauthCallback: () => '/redirect/oauth2', oauthCallback: () => '/redirect/oauth2',
firebaseTest: () => '/firebaseTest',
}; };
const router = createBrowserRouter([ const router = createBrowserRouter([
@ -151,14 +149,6 @@ const router = createBrowserRouter([
</Suspense> </Suspense>
), ),
}, },
{
path: webPath.firebaseTest(),
element: (
<Suspense fallback={<div></div>}>
<FirebaseTest />
</Suspense>
),
},
]); ]);
export default router; export default router;

View File

@ -346,3 +346,10 @@ export interface ReportResponse {
epochTime: number; epochTime: number;
leftSecond: number; leftSecond: number;
} }
export interface AlarmResponse {
id: number;
isRead: boolean;
createdAt: string;
type: string;
}