#325 (#291 후속): - VideoSseController.bulkExtract: Math.random() → ThreadLocalRandom 통일 (bulkTranscript와 일관) - VideoSseController.rebuildVectors: 즉시 complete(total=0) 대신 명시적 'not_implemented' SSE 이벤트로 운영자 가시성 확보 + timeout 600s → 60s - YouTubeService.getTranscript JavaDoc: mode 인자가 youtube-transcript-api 폴백에서만 사용된다는 점, 브라우저 추출은 mode 무관 명시 #319 (#301 후속): - RestaurantDetail: buildSearchQuery 헬퍼 추출 (외부 지도 검색 URL 조합) '한국' 단독 region 더미 케이스 가드 포함 - BottomSheet SNAP_POINTS/VELOCITY_THRESHOLD 정책 fn-doc 신규 (docs/design/279-frontend-restaurant-detail/fn-bottomsheet-snap.md) #344 (#283 후속): - globals.css에 --z-bottom-sheet=50, --z-filter-sheet=60, --z-modal=70 토큰 - LoginMenu: zIndex 99999 매직 넘버 → var(--z-modal) Refs: #319 #325 #344
88 lines
3.3 KiB
TypeScript
88 lines
3.3 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { GoogleLogin } from "@react-oauth/google";
|
|
import { useEscapeKey, useFocusTrap, useBodyScrollLock } from "@/lib/hooks/useModalA11y";
|
|
|
|
interface LoginMenuProps {
|
|
onGoogleSuccess: (credential: string) => void;
|
|
}
|
|
|
|
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
const titleId = "login-dialog-title";
|
|
|
|
// #283 — 모달 접근성: ESC / focus trap / body scroll lock
|
|
useEscapeKey(open, () => setOpen(false));
|
|
useFocusTrap(open, dialogRef);
|
|
useBodyScrollLock(open);
|
|
|
|
const handleSuccess = (res: { credential?: string }) => {
|
|
setErrorMsg(null);
|
|
if (res.credential) {
|
|
onGoogleSuccess(res.credential);
|
|
setOpen(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 border border-gray-300 dark:border-gray-600 hover:border-brand-400 dark:hover:border-brand-500 rounded-lg transition-colors"
|
|
>
|
|
로그인
|
|
</button>
|
|
|
|
{open && createPortal(
|
|
<div
|
|
// #344 — z-index 매직 넘버 99999 → CSS 변수 토큰 (--z-modal=70).
|
|
// 다른 오버레이(BottomSheet=50, FilterSheet=60) 위 일관된 stacking.
|
|
className="fixed inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
|
style={{ zIndex: "var(--z-modal)" } as React.CSSProperties}
|
|
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
|
>
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={titleId}
|
|
tabIndex={-1}
|
|
className="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-sm space-y-4"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<h3 id={titleId} className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
|
<button
|
|
onClick={() => setOpen(false)}
|
|
aria-label="로그인 창 닫기"
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none p-2 -m-2"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-gray-400 dark:text-gray-500">소셜 계정으로 간편 로그인</p>
|
|
{errorMsg && (
|
|
<p role="alert" className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/40 rounded p-2">
|
|
{errorMsg}
|
|
</p>
|
|
)}
|
|
<div className="flex flex-col items-center gap-3">
|
|
<GoogleLogin
|
|
onSuccess={handleSuccess}
|
|
onError={() => setErrorMsg("Google 로그인에 실패했습니다. 팝업 차단 또는 네트워크 상태를 확인해주세요.")}
|
|
size="large"
|
|
width="260"
|
|
text="signin_with"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
);
|
|
}
|