kcg-monitoring/frontend/src/components/auth/LoginPage.tsx
htlee 2534faa488 feat: 프론트엔드 모노레포 이관 + signal-batch 연동 + Tailwind/i18n/테마 전환
- frontend/ 폴더로 프론트엔드 전체 이관
- signal-batch API 연동 (한국 선박 위치 데이터)
- Tailwind CSS 4 + CSS 변수 테마 토큰 (dark/light)
- i18next 다국어 (ko/en) 인프라 + 28개 컴포넌트 적용
- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 범례)
- Google OAuth 로그인 화면 + DEV LOGIN 우회
- 외부 API CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak)
- ShipLayer 이미지 탭 전환 (signal-batch / MarineTraffic)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 13:54:41 +09:00

188 lines
5.2 KiB
TypeScript

import { useEffect, useRef, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface LoginPageProps {
onGoogleLogin: (credential: string) => Promise<void>;
onDevLogin: () => void;
}
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const IS_DEV = import.meta.env.DEV;
function useGoogleIdentity(onCredential: (credential: string) => void) {
const btnRef = useRef<HTMLDivElement>(null);
const callbackRef = useRef(onCredential);
callbackRef.current = onCredential;
useEffect(() => {
if (!GOOGLE_CLIENT_ID) return;
const scriptId = 'google-gsi-script';
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
const initGoogle = () => {
const google = (window as unknown as Record<string, unknown>).google as {
accounts: {
id: {
initialize: (config: {
client_id: string;
callback: (response: { credential: string }) => void;
}) => void;
renderButton: (
el: HTMLElement,
config: {
theme: string;
size: string;
width: number;
text: string;
},
) => void;
};
};
} | undefined;
if (!google?.accounts?.id || !btnRef.current) return;
google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID,
callback: (response: { credential: string }) => {
callbackRef.current(response.credential);
},
});
google.accounts.id.renderButton(btnRef.current, {
theme: 'outline',
size: 'large',
width: 300,
text: 'signin_with',
});
};
if (!script) {
script = document.createElement('script');
script.id = scriptId;
script.src = 'https://accounts.google.com/gsi/client';
script.async = true;
script.defer = true;
script.onload = initGoogle;
document.head.appendChild(script);
} else {
initGoogle();
}
}, []);
return btnRef;
}
const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
const { t } = useTranslation();
const [error, setError] = useState<string | null>(null);
const handleGoogleCredential = useCallback(
(credential: string) => {
setError(null);
onGoogleLogin(credential).catch(() => {
setError(t('auth.loginFailed'));
});
},
[onGoogleLogin, t],
);
const googleBtnRef = useGoogleIdentity(handleGoogleCredential);
return (
<div
className="flex min-h-screen items-center justify-center"
style={{ backgroundColor: 'var(--kcg-bg)' }}
>
<div
className="flex w-full max-w-sm flex-col items-center gap-6 rounded-xl border p-8"
style={{
backgroundColor: 'var(--kcg-card)',
borderColor: 'var(--kcg-border)',
}}
>
{/* Title */}
<div className="flex flex-col items-center gap-2">
<div className="text-3xl">&#x1f6e1;&#xfe0f;</div>
<h1
className="text-xl font-bold"
style={{ color: 'var(--kcg-text)' }}
>
{t('auth.title')}
</h1>
<p
className="text-sm"
style={{ color: 'var(--kcg-muted)' }}
>
{t('auth.subtitle')}
</p>
</div>
{/* Error */}
{error && (
<div
className="w-full rounded-lg px-4 py-2 text-center text-sm"
style={{
backgroundColor: 'var(--kcg-danger-bg)',
color: 'var(--kcg-danger)',
}}
>
{error}
</div>
)}
{/* Google Login Button */}
{GOOGLE_CLIENT_ID && (
<>
<div ref={googleBtnRef} />
<p
className="text-xs"
style={{ color: 'var(--kcg-dim)' }}
>
{t('auth.domainNotice')}
</p>
</>
)}
{/* Dev Login */}
{IS_DEV && (
<>
<div
className="w-full border-t pt-4 text-center"
style={{ borderColor: 'var(--kcg-border)' }}
>
<span
className="text-xs font-mono tracking-wider"
style={{ color: 'var(--kcg-dim)' }}
>
{t('auth.devNotice')}
</span>
</div>
<button
type="button"
onClick={onDevLogin}
className="w-full cursor-pointer rounded-lg border-2 px-4 py-3 text-sm font-bold transition-colors"
style={{
borderColor: 'var(--kcg-danger)',
color: 'var(--kcg-danger)',
backgroundColor: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--kcg-danger-bg)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{t('auth.devLogin')}
</button>
</>
)}
</div>
</div>
);
};
export default LoginPage;