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:
commit
557be93670
38
frontend/package-lock.json
generated
38
frontend/package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@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-scroll-area": "^1.1.0",
|
||||
"@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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
|
||||
|
@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@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-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.1.1",
|
||||
|
22
frontend/src/api/alarmApi.ts
Normal file
22
frontend/src/api/alarmApi.ts
Normal 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);
|
||||
}
|
@ -14,7 +14,7 @@ export async function logout() {
|
||||
return api.post('/auth/logout').then(({ data }) => data);
|
||||
}
|
||||
|
||||
export async function saveFcmToken() {
|
||||
export async function getAndSaveFcmToken() {
|
||||
const fcmToken = await getFcmToken();
|
||||
return api.post('/auth/fcm', { token: fcmToken }).then(({ data }) => ({ data, fcmToken }));
|
||||
}
|
||||
|
187
frontend/src/components/Header/AlarmPopover.tsx
Normal file
187
frontend/src/components/Header/AlarmPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { useLocation, Link } from 'react-router-dom';
|
||||
import UserProfileModal from './UserProfileModal';
|
||||
import WorkspaceNavigation from './WorkspaceNavigation';
|
||||
import useAuthStore from '@/stores/useAuthStore';
|
||||
import { Suspense } from 'react';
|
||||
import AlarmPopover from './AlarmPopover';
|
||||
|
||||
export interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
@ -39,8 +39,8 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
</div>
|
||||
|
||||
{!isHomePage && profile && (
|
||||
<div className="flex items-center gap-4 md:gap-5">
|
||||
<Bell className="h-4 w-4 text-black sm:h-5 sm:w-5" />
|
||||
<div className="flex items-center gap-2">
|
||||
<AlarmPopover />
|
||||
<UserProfileModal />
|
||||
</div>
|
||||
)}
|
||||
|
29
frontend/src/components/ui/popover.tsx
Normal file
29
frontend/src/components/ui/popover.tsx
Normal 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 }
|
@ -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>
|
||||
);
|
||||
}
|
13
frontend/src/queries/alarms/useCreateAlarmTestQuery.ts
Normal file
13
frontend/src/queries/alarms/useCreateAlarmTestQuery.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
13
frontend/src/queries/alarms/useDeleteAlarmQuery.ts
Normal file
13
frontend/src/queries/alarms/useDeleteAlarmQuery.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
13
frontend/src/queries/alarms/useDeleteAllAlarmQuery.ts
Normal file
13
frontend/src/queries/alarms/useDeleteAllAlarmQuery.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
9
frontend/src/queries/alarms/useGetAlarmListQuery.ts
Normal file
9
frontend/src/queries/alarms/useGetAlarmListQuery.ts
Normal 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,
|
||||
});
|
||||
}
|
13
frontend/src/queries/alarms/useReadAlarmQuery.ts
Normal file
13
frontend/src/queries/alarms/useReadAlarmQuery.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
12
frontend/src/queries/alarms/useResetAlarmListQuery.ts
Normal file
12
frontend/src/queries/alarms/useResetAlarmListQuery.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
9
frontend/src/queries/auth/useFcmTokenQuery.ts
Normal file
9
frontend/src/queries/auth/useFcmTokenQuery.ts
Normal 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,
|
||||
});
|
||||
}
|
@ -20,7 +20,6 @@ import NotFound from '@/pages/NotFound';
|
||||
import ReviewRequest from '@/pages/ReviewRequest';
|
||||
import ModelIndex from '@/pages/ModelIndex';
|
||||
import ModelDetail from '@/pages/ModelDetail';
|
||||
import FirebaseTest from '@/pages/FirebaseTest';
|
||||
|
||||
export const webPath = {
|
||||
home: () => '/',
|
||||
@ -28,7 +27,6 @@ export const webPath = {
|
||||
workspace: () => '/workspace',
|
||||
admin: () => `/admin`,
|
||||
oauthCallback: () => '/redirect/oauth2',
|
||||
firebaseTest: () => '/firebaseTest',
|
||||
};
|
||||
|
||||
const router = createBrowserRouter([
|
||||
@ -151,14 +149,6 @@ const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: webPath.firebaseTest(),
|
||||
element: (
|
||||
<Suspense fallback={<div></div>}>
|
||||
<FirebaseTest />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
export default router;
|
||||
|
@ -346,3 +346,10 @@ export interface ReportResponse {
|
||||
epochTime: number;
|
||||
leftSecond: number;
|
||||
}
|
||||
|
||||
export interface AlarmResponse {
|
||||
id: number;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
type: string;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user