Files
tasteby/frontend/src/components/LoginMenu.tsx
joungmin 437e709a8d feat: P5-1 작은 후속 묶음 (#319+#325+#344)
#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
2026-06-15 14:40:45 +09:00

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,
)}
</>
);
}