Merge branch 'fe/develop' of https://lab.ssafy.com/s11-s-project/S11P21S002 into fe/refactor/header

This commit is contained in:
정현조 2024-09-29 22:05:39 +09:00
commit ec54881187
20 changed files with 421 additions and 82 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

@ -1,5 +1,6 @@
import api from '@/api/axiosConfig'; import api from '@/api/axiosConfig';
import { MemberResponse, RefreshTokenResponse } from '@/types'; import { MemberResponse, RefreshTokenResponse } from '@/types';
import { getFcmToken } from './firebaseConfig';
export async function reissueToken() { export async function reissueToken() {
return api.post<RefreshTokenResponse>('/auth/reissue', null, { withCredentials: true }).then(({ data }) => data); return api.post<RefreshTokenResponse>('/auth/reissue', null, { withCredentials: true }).then(({ data }) => data);
@ -13,8 +14,9 @@ export async function logout() {
return api.post('/auth/logout').then(({ data }) => data); return api.post('/auth/logout').then(({ data }) => data);
} }
export async function saveFcmToken(token: string) { export async function getAndSaveFcmToken() {
return api.post('/auth/fcm', { token }).then(({ data }) => data); const fcmToken = await getFcmToken();
return api.post('/auth/fcm', { token: fcmToken }).then(({ data }) => ({ data, fcmToken }));
} }
export async function createFcmNotification() { export async function createFcmNotification() {

View File

@ -1,5 +1,5 @@
import { initializeApp } from 'firebase/app'; import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging'; import { getMessaging, onMessage } from 'firebase/messaging';
const firebaseConfig = { const firebaseConfig = {
apiKey: String(import.meta.env.VITE_FIREBASE_API_KEY), apiKey: String(import.meta.env.VITE_FIREBASE_API_KEY),
@ -22,30 +22,31 @@ const getFcmToken = async () => {
return existingToken; return existingToken;
} }
try { // try {
const permission = await Notification.requestPermission(); // const permission = await Notification.requestPermission();
if (permission === 'granted') { // if (permission === 'granted') {
console.log('알림 권한이 허용되었습니다.'); // console.log('알림 권한이 허용되었습니다.');
console.log('FCM 토큰 발급 중...'); // console.log('FCM 토큰 발급 중...');
const currentToken = await getToken(messaging, { // const currentToken = await getToken(messaging, {
vapidKey: 'BApIruZrx83suCd09dnDCkFSP_Ts08q38trrIL6GHpChtbjQHTHk_38_JRyTiKLqciHxLQ8iXtie3lvgyb4Iphg', // vapidKey: 'BApIruZrx83suCd09dnDCkFSP_Ts08q38trrIL6GHpChtbjQHTHk_x38_JRyTiKLqciHxLQ8iXtie3lvgyb4Iphg',
}); // });
console.log('FCM 토큰 발급 성공'); // console.log('FCM 토큰 발급 성공');
if (currentToken) { // if (currentToken) {
sessionStorage.setItem('fcmToken', currentToken); // sessionStorage.setItem('fcmToken', currentToken);
return currentToken; // return currentToken;
} // }
console.warn('FCM 토큰을 가져올 수 없습니다.'); // console.warn('FCM 토큰을 가져올 수 없습니다.');
} else { // } else {
console.log('알림 권한이 거부되었습니다.'); // console.log('알림 권한이 거부되었습니다.');
} // }
} catch (error) { // } catch (error) {
console.error('FCM 토큰을 가져오는 중 오류가 발생했습니다. : ', error); // console.error('FCM 토큰을 가져오는 중 오류가 발생했습니다. : ', error);
} // }
return null;
}; };
const handleForegroundMessages = () => { const handleForegroundMessages = () => {

View File

@ -0,0 +1,74 @@
import { cn } from '@/lib/utils';
import { AlarmResponse } from '@/types';
import { Mail, MailOpen, Trash2 } from 'lucide-react';
export default function AlarmItem({
alarm,
onRead,
onDelete,
}: {
alarm: AlarmResponse;
onRead: (alarmId: number) => void;
onDelete: (alarmId: number) => void;
}) {
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 handleRead = () => {
onRead(alarm.id);
};
const handleDelete = () => {
onDelete(alarm.id);
};
return (
<div 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={cn('body-small', alarm.isRead ? 'text-gray-400' : 'text-black')}>
[{alarm.id}] {alarm.type} .
</p>
<p className="caption text-gray-500">{timeAgo(alarm.createdAt)}</p>
</div>
{alarm.isRead ? (
<button
className="p-1"
disabled
>
<MailOpen
size={16}
className="stroke-gray-400"
/>
</button>
) : (
<button
className="p-1"
onClick={handleRead}
>
<Mail size={16} />
</button>
)}
<button
className="p-1"
onClick={handleDelete}
>
<Trash2
size={16}
className="stroke-red-500"
/>
</button>
</div>
);
}

View File

@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { Bell } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { onMessage } from 'firebase/messaging';
import { messaging } from '@/api/firebaseConfig';
import AlarmItem from './AlarmItem';
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';
export default function AlarmPopover() {
const [unread, setUnread] = useState<boolean>(false);
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>
) : (
<div className="flex max-h-[500px] w-full flex-col items-center overflow-y-auto">
{alarms
.slice()
.reverse()
.map((alarm) => (
<AlarmItem
key={alarm.id}
alarm={alarm}
onRead={handleReadAlarm}
onDelete={handleDeleteAlarm}
/>
))}
</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

@ -170,7 +170,7 @@ export default function ImageCanvas() {
const id = labels.length; const id = labels.length;
addLabel({ addLabel({
id: id, id: id,
categoryId: 0, categoryId: categories[0]!.id,
type: 'polygon', type: 'polygon',
color: `#${color}`, color: `#${color}`,
coordinates: polygonPoints.slice(0, -1), coordinates: polygonPoints.slice(0, -1),
@ -200,7 +200,7 @@ export default function ImageCanvas() {
const id = labels.length; const id = labels.length;
addLabel({ addLabel({
id: id, id: id,
categoryId: 0, categoryId: categories[0]!.id,
type: 'rectangle', type: 'rectangle',
color: `#${color}`, color: `#${color}`,
coordinates: rectPoints, coordinates: rectPoints,

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,44 +0,0 @@
import { createFcmNotification, saveFcmToken } from '@/api/authApi';
import { getFcmToken, handleForegroundMessages } from '@/api/firebaseConfig';
import { Button } from '@/components/ui/button';
export default function FirebaseTest() {
const handleSaveFcmToken = async () => {
const fcmToken = await getFcmToken();
if (fcmToken) {
await saveFcmToken(fcmToken);
return;
}
console.log('FCM 토큰이 없습니다.');
};
const handleCreateNotification = async () => {
await createFcmNotification();
};
handleForegroundMessages();
return (
<div>
<h1 className="heading p-2">hello, firebase!</h1>
<div className="p-2">
<Button
onClick={handleSaveFcmToken}
variant="outlinePrimary"
className="mr-2"
>
FCM
</Button>
<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

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