diff --git a/components/DisconnectionModal.tsx b/components/DisconnectionModal.tsx new file mode 100644 index 0000000..12dea1c --- /dev/null +++ b/components/DisconnectionModal.tsx @@ -0,0 +1,31 @@ +import Modal from './Modal'; + +interface DisconnectionModalProps { + isOpen: boolean; + onClose: () => void; +} + +const DisconnectionModal = ({ isOpen, onClose }: DisconnectionModalProps) => { + return ( + +
+

+ 5분 이상 글을 쓰지 않아 접속이 끊어졌어요. +

+

+ 위키 참여하기를 통해 다시 위키를 수정해 주세요. +

+
+ +
+
+
+ ); +}; + +export default DisconnectionModal; diff --git a/components/ImageUploadModal.tsx b/components/ImageUploadModal.tsx new file mode 100644 index 0000000..a844867 --- /dev/null +++ b/components/ImageUploadModal.tsx @@ -0,0 +1,190 @@ +import Image from 'next/image'; +import { useEffect, useRef, useState } from 'react'; + +import Modal from './Modal'; + +interface ImageUploadModalProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * 이미지 업로드 모달 컴포넌트 + * @param isOpen 모달 오픈 여부 + * @param onClose 모달 닫기 함수 + * @returns ImageUploadModal 컴포넌트 + */ + +const ImageUploadModal = ({ isOpen, onClose }: ImageUploadModalProps) => { + // input 요소를 참조하기 위한 ref + const fileInputRef = useRef(null); + + // 선택한 이미지의 미리보기 url + const [previewUrl, setPreviewUrl] = useState(null); + + const [isDragging, setIsDragging] = useState(false); // 드래그 중인지 여부 + + const [isUpload, setIsUpload] = useState(false); + + // 카메라 아이콘 클릭시 input 요소 클릭이 되도록 + const handleCameraClick = () => { + fileInputRef.current?.click(); + }; + + // 파일 선택시 + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + // 파일을 url로 변환하여 미리보기 생성 + const imageUrl = URL.createObjectURL(file); + setPreviewUrl(imageUrl); + } + }; + + // 드래그 관련 이벤트 + // 드래그 시작 + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); // 이벤트 전파 중지 + setIsDragging(true); + }; + // 드래그가 영역을 벗어날 때 + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + // 드래그 중 + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + // 드래그로 파일을 드랍했을 때 + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const file = e.dataTransfer.files?.[0]; + if (file) { + const imageUrl = URL.createObjectURL(file); + setPreviewUrl(imageUrl); + } + }; + + const handleImageUpload = async () => { + try { + setIsUpload(true); + // 이미지 업로그 api 통신 로직 + await new Promise((resolve) => setTimeout(resolve, 1000)); + setIsUpload(false); + alert('이미지 업로드 성공'); + onClose(); + } catch (error) { + console.error('이미지 업로드 error:', error); + alert('이미지 업로드 중 오류가 발생했습니다.'); + } finally { + setIsUpload(false); + } + }; + + useEffect(() => { + return () => { + // 이전 미리보기 url 정리 + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + setPreviewUrl(null); + } + }; + }, [previewUrl, isOpen]); + + return ( + +
+

이미지

+ + + {previewUrl ? ( +
+ 선택된 이미지 +
+ ) : ( +
+ 카메라 아이콘 +

+ 클릭 또는 이미지를 드래그하여 올려주세요 +

+
+ )} +
+ +
+
+
+ ); +}; + +export default ImageUploadModal; diff --git a/components/Modal.tsx b/components/Modal.tsx new file mode 100644 index 0000000..8df0d10 --- /dev/null +++ b/components/Modal.tsx @@ -0,0 +1,74 @@ +import Image from 'next/image'; +import { useEffect } from 'react'; + +interface ModalProps { + isOpen: boolean; // 모달 오픈 여부 + onClose: () => void; // 모달 닫기 함수 + children: React.ReactNode; // 모달 내용 + width?: string; // 모달 너비 +} + +/** + * 모달 공통 레이아웃 컴포넌트 + * @param isOpen 모달 오픈 여부 + * @param onClose 모달 닫기 함수 + * @param children 모달 내용 + * @param width 모달 너비 + * @returns Modal 컴포넌트 + */ + +const Modal = ({ + isOpen, + onClose, + children, + width = 'w-11/12 ta:w-3/4 pc:w-1/2', +}: ModalProps) => { + // 모달이 닫혀있으면 null 반환 + if (!isOpen) return null; + + // 배경 클릭시 모달 닫기 + const handleBackGroundClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + // esc 키로 모달 닫기 + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handleEsc); + return () => window.removeEventListener('keydown', handleEsc); + }, []); + + return ( +
+
+ {/* 닫기 버튼 영역 */} +
+ 닫기 아이콘 +
+ {/* 컨텐츠 영역 */} +
{children}
+
+
+ ); +}; + +export default Modal; diff --git a/components/UnsavedChangesModal.tsx b/components/UnsavedChangesModal.tsx new file mode 100644 index 0000000..0d88144 --- /dev/null +++ b/components/UnsavedChangesModal.tsx @@ -0,0 +1,27 @@ +import Modal from './Modal'; + +interface UnsavedChangesModalProps { + isOpen: boolean; + onClose: () => void; +} + +const UnsavedChangesModal = ({ isOpen, onClose }: UnsavedChangesModalProps) => { + return ( + +
+

저장하지 않고 나가시겠어요?

+

작성하신 모든 내용이 사라집니다.

+
+ +
+
+
+ ); +}; + +export default UnsavedChangesModal; diff --git a/components/WikiQuizModal.tsx b/components/WikiQuizModal.tsx new file mode 100644 index 0000000..b0ea980 --- /dev/null +++ b/components/WikiQuizModal.tsx @@ -0,0 +1,174 @@ +import Image from 'next/image'; +import { useEffect, useRef, useState } from 'react'; + +import Modal from './Modal'; + +interface WikiQuizModalProps { + isOpen: boolean; // 모달 오픈 여부 + onClose: () => void; // 모달 닫기 함수 + securityQuestion: string; // from user.profile.code + securityAnswer: string; + onQuizComplete: () => void; // 퀴즈 완료 함수 +} + +interface WarningTextProps { + warning: boolean; +} + +const WarningText = ({ warning }: WarningTextProps) => { + if (!warning) return null; + return ( +

정답이 아닙니다. 다시 입력해 주세요.

+ ); +}; + +/** + * 위키 퀴즈 모달 컴포넌트 + * @param isOpen 모달 오픈 여부 + * @param onClose 모달 닫기 함수 + * @param securityQuestion 질문 + * @param securityAnswer 답변 + * @param onQuizComplete 퀴즈 완료 함수 + * @returns WikiQuizModal 컴포넌트 + */ + +const WikiQuizModal = ({ + isOpen, + onClose, + securityQuestion, + securityAnswer, + onQuizComplete, +}: WikiQuizModalProps) => { + const [isCorrect, setIsCorrect] = useState(false); + const [userAnswer, setUserAnswer] = useState(''); + const [warning, setWarning] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const inputRef = useRef(null); + + // 정답 타이핑 관리 함수 + const handleUserAnswer = (e: React.ChangeEvent) => { + setUserAnswer(e.target.value); + setWarning(false); // 타이핑시 경고문 제거 + }; + + const handleQuizSubmit = async (e: React.FormEvent) => { + e.preventDefault(); // 새로고침 방지 + try { + if (!userAnswer) return; + + setIsSubmitting(true); // 제출 시 로딩 ui 표시 + // api 통신 로직 + await new Promise((resolve) => setTimeout(resolve, 1000)); // 실제 통신 대신 시뮬레이션션 + // 정답이면 + // { + // "registeredAt": "2024-12-16T07:56:44.829Z", + // "userId": 1799 + // } + // 오답이면 + // { + // "message": "보안 답변이 일치하지 않습니다." + // } + + // 답변 비교 전에 trim() + const cleanUserAnswer = userAnswer.trim(); + const cleanSecurityAnswer = securityAnswer?.trim() || ''; + if (cleanUserAnswer === cleanSecurityAnswer) { + setIsCorrect(true); + setWarning(false); + onQuizComplete(); + } else { + setWarning(true); + setUserAnswer(''); // 오답시 입력값 초기화 + inputRef.current?.focus(); // 오답시 input에 포커스 + } + } catch (error) { + console.error('Quiz 제출 error:', error); + alert('퀴즈 제출 중 오류가 발생했습니다.'); + } finally { + setIsSubmitting(false); // 제출 완료 시 로딩 종료 + } + }; + + useEffect(() => { + // 모달 오픈시 input 포커스 + if (isOpen) { + inputRef.current?.focus(); + } + setIsCorrect(false); // 모달 오픈시 정답 초기화 + setUserAnswer(''); // 모달 오픈시 입력값 초기화 + setWarning(false); // 모달 오픈시 경고문 초기화 + setIsCorrect(false); // 모달 오픈시 정답 초기화 + }, [isOpen]); + + return ( + +
+ 자물쇠 아이콘 +

다음 퀴즈를 맞추고

+

위키를 작성해 보세요.

+
+ {securityQuestion} +
+ + + + +
+

위키드는 지인들과 함께하는 즐거운 공간입니다.

+

지인에게 상처를 주지 않도록 작성해 주세요.

+
+
+ ); +}; + +export default WikiQuizModal; diff --git a/lib/axios-client.ts b/lib/axios-client.ts new file mode 100644 index 0000000..c6a45c5 --- /dev/null +++ b/lib/axios-client.ts @@ -0,0 +1,126 @@ +'use client'; +import axios, { + AxiosError, + AxiosRequestConfig, + InternalAxiosRequestConfig, +} from 'axios'; + +const baseURL = process.env.NEXT_PUBLIC_API_URL; + +// 토큰 인증이 필요 없는 api 경로 +const PUBLIC_ENDPOINTS = [ + '/auth/signUp', + '/auth/signIn', + '/auth/refresh-token', +]; + +/** + * 인증 처리가 포함된 커스텀 axios 인스턴스 + * 클라이언트 컴포넌트에서만 사용 + * 서버 컴포넌트에서는 API 라우트 활용 + * 주요 기능: + * - 보호된 엔드포인트에 자동으로 인증 토큰 추가 + * - 공개 엔드포인트(로그인, 회원가입 등)는 토큰 추가 제외 + * - 401 에러 발생 시 토큰 갱신 처리 + * - 토큰 저장소 관리 및 정리 + * - 인증 실패 시 로그인 페이지로 리다이렉트 + */ + +// axios 인스턴스 생성 +export const instance = axios.create({ + baseURL: baseURL, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +}); + +// 주어진 url이 공개 엔드포인트인지 확인 +const isPublicEndPoint = (url: string | undefined): boolean => { + if (!url) return false; + return PUBLIC_ENDPOINTS.some((endpoint) => url.includes(endpoint)); +}; + +// 토큰 갱신 함수 +// 새로운 액세스 토큰을 반환하거나 갱신 실패 시 null 반환환 +const refreshToken = async (): Promise => { + try { + // 가지고 있는 리프레시 토큰으로 액세스 토큰 갱신 + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + const response = await axios.post(`${baseURL}/auth/refresh-token`, { + refreshToken, + }); + const newAccessToken = response.data.accessToken; + localStorage.setItem('accessToken', newAccessToken); + return newAccessToken; + } catch (error) { + // 리프레시 토큰도 만료된 경우 + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + window.location.href = '/login'; // 로그인 페이지로 리다이렉트 + return null; + } +}; + +if (typeof window !== 'undefined') { + // Request 인터셉터 + instance.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + if (isPublicEndPoint(config.url)) return config; // 공개 엔드포인트는 토큰 추가하지 않음 + + const token = localStorage.getItem('accessToken'); + if (token) { + config.headers = config.headers || {}; // headers 초기화 + config.headers.set('Authorization', `Bearer ${token}`); + } + return config; + }, + (error: AxiosError) => { + return Promise.reject(error); + } + ); + + // Response 인터셉터 + // 토큰 만료를 감지하고 토큰 갱신 후 요청 재시도(_retry 플래그 사용) + instance.interceptors.response.use( + (response) => { + return response; + }, + async (error: AxiosError) => { + const originalRequest = error.config as AxiosRequestConfig & { + _retry?: boolean; + }; + + // 공개 엔드포인트는 토큰 갱신 처리하지 않음 + if (isPublicEndPoint(originalRequest.url)) { + return Promise.reject(error); + } + + // 토큰 만료 에러 (401) && 아직 재시도하지 않은 요청 + if ( + error.response?.status === 401 && + originalRequest && + !originalRequest._retry + ) { + originalRequest._retry = true; + + const newToken = await refreshToken(); // 토큰 갱신 함수로 새 토큰 발급 + if (newToken) { + // 새로운 토큰으로 원래 요청 재시도 + originalRequest.headers = { + ...originalRequest.headers, + Authorization: `Bearer ${newToken}`, + }; + return instance(originalRequest); + } + } + + return Promise.reject(error); + } + ); +} + +export default instance; diff --git a/pages/test/modal.tsx b/pages/test/modal.tsx new file mode 100644 index 0000000..b41a84d --- /dev/null +++ b/pages/test/modal.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; + +import DisconnectionModal from '@/components/DisconnectionModal'; +import ImageUploadModal from '@/components/ImageUploadModal'; +import UnsavedChangesModal from '@/components/UnsavedChangesModal'; +import WikiQuizModal from '@/components/WikiQuizModal'; + +const QUESTION = '특별히 싫어하는 음식은?'; +const ANSWER = '카레'; + +function disconnect() { + const [isDMOpen, setIsDMOpen] = useState(false); + const [isUCOpen, setIsUCOpen] = useState(false); + const [isQuizOpen, setIsQuizOpen] = useState(false); + const [isImageOpen, setIsImageOpen] = useState(false); + const onUCClose = () => setIsUCOpen(false); + const onDMClose = () => setIsDMOpen(false); + const onQuizClose = () => setIsQuizOpen(false); + const onImageClose = () => setIsImageOpen(false); + + const handleQuizSuccess = () => { + alert('퀴즈를 성공하셨습니다.'); + setIsQuizOpen(false); + }; + + return ( +
+ + + + + + + + +
+ ); +} + +export default disconnect; diff --git a/public/icon/icon-camera.png b/public/icon/icon-camera.png new file mode 100644 index 0000000..b26cf43 Binary files /dev/null and b/public/icon/icon-camera.png differ diff --git a/public/icon/icon-close.svg b/public/icon/icon-close.svg new file mode 100644 index 0000000..b6181e2 --- /dev/null +++ b/public/icon/icon-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icon/icon-lock.png b/public/icon/icon-lock.png new file mode 100644 index 0000000..c318720 Binary files /dev/null and b/public/icon/icon-lock.png differ diff --git a/tsconfig.json b/tsconfig.json index bc5a593..d17e278 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,7 @@ "@/components/*": ["components/*"], "@/styles/*": ["styles/*"], "@/pages/*": ["pages/*"], + "@/services/*": ["services/*"] "@/utils/*": ["utils/*"], "@/hooks/*": ["hooks/*"] }