From 0b04263530169e1b05c6f26d8f6ed83e34e98346 Mon Sep 17 00:00:00 2001 From: cl-o-lc Date: Fri, 8 Aug 2025 16:18:23 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20header=EC=9D=98=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header 컴포넌트에 onLoginClick prop 정의 - 로그인 버튼에 onClick 속성 연결 - 스타일 클래스 적용 및 UI 수정 --- src/components/ui/Header.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index 81e8821..b7fba52 100644 --- a/src/components/ui/Header.tsx +++ b/src/components/ui/Header.tsx @@ -4,14 +4,13 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; import clsx from 'clsx'; -export default function Header() { +export default function Header({ onLoginClick }: { onLoginClick: () => void }) { const [scrolled, setScrolled] = useState(false); useEffect(() => { const handleScroll = () => { setScrolled(window.scrollY > 100); }; - window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []); @@ -55,18 +54,13 @@ export default function Header() { {/* 액션 버튼 */}
- - 로그인 - - - 시작하기 - + 로그인 +
From 444f043aba84c6ca520eac4a724a4aed6f722e6e Mon Sep 17 00:00:00 2001 From: cl-o-lc Date: Fri, 8 Aug 2025 16:20:28 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoginModal, SignupModal UI 및 기본 동작 추가 --- src/components/auth/LoginModal.tsx | 174 ++++++++++++++++++++ src/components/auth/SignupModal.tsx | 244 ++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 src/components/auth/LoginModal.tsx create mode 100644 src/components/auth/SignupModal.tsx diff --git a/src/components/auth/LoginModal.tsx b/src/components/auth/LoginModal.tsx new file mode 100644 index 0000000..ecab1f6 --- /dev/null +++ b/src/components/auth/LoginModal.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type Props = { + open: boolean; + onClose: () => void; + onSubmit?: (payload: { + email: string; + password: string; + }) => Promise | void; + onSignupClick?: () => void; // 회원가입 열기 +}; + +export default function LoginModal({ + open, + onClose, + onSubmit, + onSignupClick, +}: Props) { + const [showPw, setShowPw] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + const email = String(fd.get('email') || ''); + const password = String(fd.get('password') || ''); + setLoading(true); + try { + await onSubmit?.({ email, password }); + onClose(); + } finally { + setLoading(false); + } + } + + return ( +
+ {/* backdrop (배경 클릭으로 닫기) */} + + + {/* 로고 섹션 (logo-section) */} +
+
+
+ ⏰ +
+
Check Time
+
+

계정에 로그인하세요

+
+ + {/* 로그인 폼 (login-form) */} +
+
+ + +
+ +
+ +
+ + +
+
+ +
+
+ + +
+ + {/* 구분선 (divider) */} +
+ +
+ + {/* 하단 링크 (bottom-link) */} +

+ 아직 계정이 없으신가요?{' '} + +

+
+ + ); +} diff --git a/src/components/auth/SignupModal.tsx b/src/components/auth/SignupModal.tsx new file mode 100644 index 0000000..8471e6b --- /dev/null +++ b/src/components/auth/SignupModal.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +type Props = { + open: boolean; + onClose: () => void; + onSubmit?: (payload: { + username: string; + email: string; + password: string; + }) => Promise | void; + onLoginClick?: () => void; // 로그인 모달 열기 +}; + +export default function SignupModal({ + open, + onClose, + onSubmit, + onLoginClick, +}: Props) { + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPw, setShowPw] = useState(false); + const [showConfirmPw, setShowConfirmPw] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + const passwordStrength = (() => { + let score = 0; + if (password.length >= 8) score++; + if (/[a-zA-Z]/.test(password)) score++; + if (/[0-9]/.test(password)) score++; + if (/[^a-zA-Z0-9]/.test(password)) score++; + return score; + })(); + + const strengthBarClass = + passwordStrength <= 1 + ? 'bg-red-500 w-1/4' + : passwordStrength === 2 + ? 'bg-yellow-500 w-1/2' + : passwordStrength === 3 + ? 'bg-blue-500 w-3/4' + : 'bg-green-500 w-full'; + + const canSubmit = + username.trim().length >= 2 && + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && + passwordStrength >= 3 && + password === confirmPassword && + !loading; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!canSubmit) return; + setLoading(true); + try { + await onSubmit?.({ username: username.trim(), email, password }); + onClose(); + } finally { + setLoading(false); + } + } + + return ( +
+ {/* backdrop */} + + {/* 로고 섹션 */} +
+
+
+ ⏰ +
+
Check Time
+
+

새로운 계정을 만드세요

+
+ {/* 폼 */} +
+
+ + setUsername(e.target.value)} + placeholder="홍길동" + required + className="w-full rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 text-sm outline-none focus:border-indigo-500 focus:bg-white focus:ring-4 focus:ring-indigo-200/50" + /> + {username && username.trim().length < 2 && ( +

+ 이름은 2자 이상 입력해주세요 +

+ )} +
+
+ + setEmail(e.target.value)} + placeholder="your@email.com" + required + className="w-full rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 text-sm outline-none focus:border-indigo-500 focus:bg-white focus:ring-4 focus:ring-indigo-200/50" + /> +
+
+ +
+ setPassword(e.target.value)} + placeholder="비밀번호를 입력하세요" + required + className="w-full rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 pr-10 text-sm outline-none focus:border-indigo-500 focus:bg-white focus:ring-4 focus:ring-indigo-200/50" + /> + +
+ {/* 강도 표시 */} +
+
+
+

+ 8자 이상, 영문/숫자/특수문자 포함 권장 +

+
+
+ +
+ setConfirmPassword(e.target.value)} + placeholder="비밀번호를 다시 입력하세요" + required + className="w-full rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 pr-10 text-sm outline-none focus:border-indigo-500 focus:bg-white focus:ring-4 focus:ring-indigo-200/50" + /> + +
+ {confirmPassword && confirmPassword !== password && ( +

+ 비밀번호가 일치하지 않습니다 +

+ )} +
+ + +

+ 이미 계정이 있으신가요?{' '} + +

+
+
+ ); +} From c06e3ff58a311f831278b463f04a5e8d1bd0a2a9 Mon Sep 17 00:00:00 2001 From: cl-o-lc Date: Fri, 8 Aug 2025 16:20:54 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=97=B4=EA=B8=B0=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - page.tsx에서 loginOpen 상태 추가 및 이벤트 연결 --- src/app/page.tsx | 77 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 3b7dc16..cede2c9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,10 +3,15 @@ import Header from '@/components/ui/Header'; import SearchForm from '@/components/ServerSearchForm'; import { useRouter } from 'next/navigation'; +import { useState } from 'react'; import KoreanStandardTime from '@/components/KoreanStandaradTime'; +import LoginModal from '@/components/auth/LoginModal'; +import SignupModal from '@/components/auth/SignupModal'; export default function Home() { const router = useRouter(); + const [signupOpen, setSignupOpen] = useState(false); + const [loginOpen, setLoginOpen] = useState(false); const handleSubmit = (url: string) => { router.push(`/result?url=${encodeURIComponent(url)}`); @@ -15,7 +20,7 @@ export default function Home() { return (
{/* Header */} -
+
setLoginOpen(true)} /> {/* Hero */}
@@ -42,6 +47,76 @@ export default function Home() {
+ + {/* 로그인 모달 */} + setLoginOpen(false)} + onSignupClick={() => { + setLoginOpen(false); + setSignupOpen(true); + }} + onSubmit={async ({ email, password }) => { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE}/api/auth/login`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }, + ); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || '로그인 실패'); + } + + // JWT 토큰 저장 (accessToken, refreshToken) + localStorage.setItem('accessToken', data.data.accessToken); + localStorage.setItem('refreshToken', data.data.refreshToken); + + console.log('로그인 성공', data.data.user); + // 필요 시 사용자 상태 관리 (예: Context/Redux/SWR) + setLoginOpen(false); + } catch (err) { + alert(err instanceof Error ? err.message : '로그인 중 오류 발생'); + } + }} + /> + + {/* 회원가입 모달 */} + setSignupOpen(false)} + onLoginClick={() => { + setSignupOpen(false); + setLoginOpen(true); + }} + onSubmit={async ({ username, email, password }) => { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE}/api/auth/register`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, email, password }), + }, + ); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || '회원가입 실패'); + } + + console.log('회원가입 성공', data.data.user); + alert('회원가입이 완료되었습니다. 로그인 해주세요.'); + setSignupOpen(false); + } catch (err) { + alert(err instanceof Error ? err.message : '회원가입 중 오류 발생'); + } + }} + />
); } From 58685d5919f88d76292b6cae95ffd6622c493982 Mon Sep 17 00:00:00 2001 From: cl-o-lc Date: Fri, 8 Aug 2025 16:39:34 +0900 Subject: [PATCH 04/12] =?UTF-8?q?chore:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20API=20base=20URL=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20.env.local=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/result/page.tsx | 2 +- src/components/KoreanStandaradTime.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/result/page.tsx b/src/app/result/page.tsx index 8876c6e..7ce3abc 100644 --- a/src/app/result/page.tsx +++ b/src/app/result/page.tsx @@ -349,7 +349,7 @@ export default function CheckTimeApp() { // 1. /api/time/compare 엔드포인트 호출 const compareResponse = await fetch( - 'http://localhost:3001/api/time/compare', + `${process.env.NEXT_PUBLIC_API_BASE}/api/time/compare`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/components/KoreanStandaradTime.tsx b/src/components/KoreanStandaradTime.tsx index e999760..6146d5f 100644 --- a/src/components/KoreanStandaradTime.tsx +++ b/src/components/KoreanStandaradTime.tsx @@ -16,7 +16,9 @@ export default function KoreanStandardTime({ const fetchTime = async () => { try { - const res = await fetch('http://localhost:3001/api/time/current'); // 배포 시 서버 주소로 변경 + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE}/api/time/current`, + ); // 배포 시 서버 주소로 변경 const data = await res.json(); if (data.success && data.data.timestamp) { From c0335ea0b0a6406c9e110f918c6310235bf6f7a7 Mon Sep 17 00:00:00 2001 From: cl-o-lc Date: Fri, 8 Aug 2025 20:42:31 +0900 Subject: [PATCH 05/12] =?UTF-8?q?style:=20layout=EC=97=90=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EB=B0=B0=EA=B2=BD=EC=83=89(bg-gray-50)=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=B4=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=8B=9C=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=20=EA=B2=80=EC=A0=95=EC=83=89=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cbf2b6c..556f4b5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -25,7 +25,7 @@ export default function RootLayout({ return ( {children} From 1cd36801103e645b2dbfa54aebefd4e1619817c7 Mon Sep 17 00:00:00 2001 From: cl-o-lc Date: Fri, 8 Aug 2025 20:47:20 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EB=B0=8F=20UI=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - home에 로그인 상태(isAuthed)와 사용자명(userName) 관리 로직 추가 - LoginModal에서 로그인 성공 시에만 모달 닫히도록 수정 - Header에 로그인 상태에 따른 사용자명 표시 및 로그아웃 버튼 추가 - 로그아웃 재확인 모달(ConfirmModal ) 추가 --- src/app/page.tsx | 64 +++++++++++++++++++++++++----- src/components/auth/LoginModal.tsx | 6 +-- src/components/ui/ConfirmModal.tsx | 57 ++++++++++++++++++++++++++ src/components/ui/Header.tsx | 47 +++++++++++++++++----- 4 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 src/components/ui/ConfirmModal.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index cede2c9..16fc76d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,24 +3,55 @@ import Header from '@/components/ui/Header'; import SearchForm from '@/components/ServerSearchForm'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import KoreanStandardTime from '@/components/KoreanStandaradTime'; import LoginModal from '@/components/auth/LoginModal'; import SignupModal from '@/components/auth/SignupModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; export default function Home() { const router = useRouter(); const [signupOpen, setSignupOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false); + const [isAuthed, setIsAuthed] = useState(false); + const [userName, setUserName] = useState(undefined); + const [confirmOpen, setConfirmOpen] = useState(false); const handleSubmit = (url: string) => { router.push(`/result?url=${encodeURIComponent(url)}`); }; + // 새로고침 시에도 로그인 유지 + useEffect(() => { + const at = localStorage.getItem('accessToken'); + const name = localStorage.getItem('userName') || undefined; + if (at) { + setIsAuthed(true); + setUserName(name); + } + }, []); + + const handleLogout = () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userName'); + setIsAuthed(false); + setUserName(undefined); + setConfirmOpen(false); + + alert('로그아웃 되었습니다.'); + router.push('/'); // 홈으로 리다이렉트 + }; + return (
{/* Header */} -
setLoginOpen(true)} /> +
setLoginOpen(true)} + isAuthed={isAuthed} + onLogoutClick={() => setConfirmOpen(true)} + userName={userName} + /> {/* Hero */}
@@ -66,21 +97,22 @@ export default function Home() { body: JSON.stringify({ email, password }), }, ); + const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || '로그인 실패'); - } + if (!res.ok) throw new Error(data.error || '로그인 실패'); - // JWT 토큰 저장 (accessToken, refreshToken) localStorage.setItem('accessToken', data.data.accessToken); localStorage.setItem('refreshToken', data.data.refreshToken); - - console.log('로그인 성공', data.data.user); - // 필요 시 사용자 상태 관리 (예: Context/Redux/SWR) - setLoginOpen(false); + if (data?.data?.user?.username) { + localStorage.setItem('userName', data.data.user.username); + setUserName(data.data.user.username); + } + setIsAuthed(true); + return true; } catch (err) { alert(err instanceof Error ? err.message : '로그인 중 오류 발생'); + return false; // 실패 시 false 반환 } }} /> @@ -112,11 +144,23 @@ export default function Home() { console.log('회원가입 성공', data.data.user); alert('회원가입이 완료되었습니다. 로그인 해주세요.'); setSignupOpen(false); + setLoginOpen(true); // 바로 로그인 유도 } catch (err) { alert(err instanceof Error ? err.message : '회원가입 중 오류 발생'); } }} /> + + {/* 로그아웃 확인 모달 */} + setConfirmOpen(false)} + />
); } diff --git a/src/components/auth/LoginModal.tsx b/src/components/auth/LoginModal.tsx index ecab1f6..c918433 100644 --- a/src/components/auth/LoginModal.tsx +++ b/src/components/auth/LoginModal.tsx @@ -8,7 +8,7 @@ type Props = { onSubmit?: (payload: { email: string; password: string; - }) => Promise | void; + }) => Promise | boolean; // 로그인 성공 시 true 반환 onSignupClick?: () => void; // 회원가입 열기 }; @@ -37,8 +37,8 @@ export default function LoginModal({ const password = String(fd.get('password') || ''); setLoading(true); try { - await onSubmit?.({ email, password }); - onClose(); + const ok = await onSubmit?.({ email, password }); + if (ok) onClose(); // 로그인 성공 시 모달 닫기 } finally { setLoading(false); } diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx new file mode 100644 index 0000000..866074e --- /dev/null +++ b/src/components/ui/ConfirmModal.tsx @@ -0,0 +1,57 @@ +'use client'; + +import React from 'react'; + +type Props = { + open: boolean; + title?: string; + message: string; + confirmText?: string; + cancelText?: string; + onConfirm: () => void; + onClose: () => void; // 취소/닫기 +}; + +export default function ConfirmModal({ + open, + title = '확인', + message, + confirmText = '확인', + cancelText = '취소', + onConfirm, + onClose, +}: Props) { + if (!open) return null; + return ( +
+ + +
+ + + + + ); +} diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index b7fba52..ec42350 100644 --- a/src/components/ui/Header.tsx +++ b/src/components/ui/Header.tsx @@ -4,7 +4,19 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; import clsx from 'clsx'; -export default function Header({ onLoginClick }: { onLoginClick: () => void }) { +type Props = { + onLoginClick: () => void; // 로그인 모달 열기 + isAuthed?: boolean; // 로그인 여부 + onLogoutClick?: () => void; // 로그아웃 함수 + userName?: string; // 사용자 이름 +}; + +export default function Header({ + onLoginClick, + isAuthed = false, + onLogoutClick, + userName, +}: Props) { const [scrolled, setScrolled] = useState(false); useEffect(() => { @@ -52,15 +64,32 @@ export default function Header({ onLoginClick }: { onLoginClick: () => void }) { - {/* 액션 버튼 */} + {/* 액션 */}
- + {isAuthed ? ( + <> + {userName && ( + + 안녕하세요, {userName}님 + + )} + + + ) : ( + + )}
From 45a709141047dda3423eac1a4071553bf82af1c3 Mon Sep 17 00:00:00 2001 From: cl-o-lc Date: Fri, 8 Aug 2025 22:13:50 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20ConfirmModal=EC=97=90=20role/?= =?UTF-8?q?aria=20=EB=B0=8F=20=EB=B2=84=ED=8A=BC=20type=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20ESC/=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dialog 메타데이터(role, aria-*) 추가 - 모든 버튼에 type="button" 명시 - 열릴 때 포커스 이동 및 ESC 키로 닫기 지원 --- src/components/ui/ConfirmModal.tsx | 58 +++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 866074e..677bceb 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useRef } from 'react'; type Props = { open: boolean; @@ -21,22 +21,68 @@ export default function ConfirmModal({ onConfirm, onClose, }: Props) { + const dialogRef = useRef(null); + const firstBtnRef = useRef(null); + + useEffect(() => { + if (!open) return; + // 열릴 때 포커스 이동 + firstBtnRef.current?.focus(); + + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [open, onClose]); + if (!open) return null; + return (
-
+ {/* 우측 상단 닫기 버튼 */}