summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-03 10:39:51 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-03 10:39:51 +0900
commit1c5dd9c10ce4264ab157f4a2479e2055a3487de4 (patch)
treee05cefb406b87d6152a7c08a5b96057528c796b5 /components
parent98d178c8fe20a61a87e5d8f20e7d310ff6fefd6b (diff)
(김준회) 벤더 로그인화면 영상 더 크게 변경
Diffstat (limited to 'components')
-rw-r--r--components/common/video-background.tsx181
-rw-r--r--components/login/login-form.tsx187
2 files changed, 256 insertions, 112 deletions
diff --git a/components/common/video-background.tsx b/components/common/video-background.tsx
new file mode 100644
index 00000000..96b9b4ac
--- /dev/null
+++ b/components/common/video-background.tsx
@@ -0,0 +1,181 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+import { cn } from '@/lib/utils';
+import { registerServiceWorker, getCacheStatus } from '@/lib/service-worker/register';
+
+interface VideoBackgroundProps {
+ videos: string[];
+ className?: string;
+ overlayClassName?: string;
+ showIndicators?: boolean;
+ showCacheStatus?: boolean;
+ onVideoChange?: (index: number) => void;
+}
+
+/**
+ * VideoBackground 컴포넌트
+ *
+ * 특징:
+ * - 영상 프리로드로 부드러운 전환
+ * - 자동 재생 및 순환
+ * - Service Worker를 통한 영구 캐싱
+ * - HTTP 캐시 헤더 지원
+ * - 인디케이터 지원
+ */
+export function VideoBackground({
+ videos,
+ className,
+ overlayClassName,
+ showIndicators = true,
+ showCacheStatus = false,
+ onVideoChange,
+}: VideoBackgroundProps) {
+ const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
+ const [isVideoLoaded, setIsVideoLoaded] = useState(false);
+ const [cachedVideos, setCachedVideos] = useState(0);
+ const videoRef = useRef<HTMLVideoElement>(null);
+ const preloadedVideos = useRef<HTMLVideoElement[]>([]);
+
+ // Service Worker 등록
+ useEffect(() => {
+ const setupServiceWorker = async () => {
+ const registration = await registerServiceWorker();
+
+ if (registration) {
+ console.log('✅ Service Worker registered - Videos will be cached');
+
+ // 캐시 상태 확인
+ setTimeout(async () => {
+ const status = await getCacheStatus();
+ if (status) {
+ setCachedVideos(status.cached);
+ console.log(`📦 Cached videos: ${status.cached}/${status.total}`);
+ }
+ }, 2000);
+ }
+ };
+
+ setupServiceWorker();
+ }, []);
+
+ // 초기 랜덤 비디오 선택
+ useEffect(() => {
+ const randomIndex = Math.floor(Math.random() * videos.length);
+ setCurrentVideoIndex(randomIndex);
+ }, [videos.length]);
+
+ // 모든 비디오 프리로드
+ useEffect(() => {
+ // 이미 프리로드된 경우 스킵
+ if (preloadedVideos.current.length > 0) return;
+
+ const preloadVideos = async () => {
+ const loadedVideos: HTMLVideoElement[] = [];
+
+ for (let i = 0; i < videos.length; i++) {
+ const video = document.createElement('video');
+ video.preload = 'auto';
+ video.muted = true;
+ video.playsInline = true;
+ video.src = videos[i];
+
+ // 메타데이터 로드 대기
+ await new Promise<void>((resolve) => {
+ video.addEventListener('loadedmetadata', () => resolve(), { once: true });
+ // 타임아웃 설정 (10초)
+ setTimeout(() => resolve(), 10000);
+ });
+
+ loadedVideos.push(video);
+ }
+
+ preloadedVideos.current = loadedVideos;
+ setIsVideoLoaded(true);
+ };
+
+ preloadVideos();
+
+ // 컴포넌트 언마운트 시 비디오 리소스 정리
+ return () => {
+ preloadedVideos.current.forEach((video) => {
+ video.src = '';
+ video.load();
+ });
+ preloadedVideos.current = [];
+ };
+ }, [videos]);
+
+ // 비디오 변경 시 콜백 호출
+ useEffect(() => {
+ if (onVideoChange) {
+ onVideoChange(currentVideoIndex);
+ }
+ }, [currentVideoIndex, onVideoChange]);
+
+ // 비디오 종료 시 다음 비디오로 전환
+ const handleVideoEnd = () => {
+ setCurrentVideoIndex((prevIndex) => (prevIndex + 1) % videos.length);
+ };
+
+ // 인디케이터 클릭 핸들러
+ const handleIndicatorClick = (index: number) => {
+ setCurrentVideoIndex(index);
+ // 비디오 즉시 재생
+ if (videoRef.current) {
+ videoRef.current.currentTime = 0;
+ videoRef.current.play();
+ }
+ };
+
+ return (
+ <div className={cn('absolute inset-0 overflow-hidden', className)}>
+ {/* 비디오 배경 */}
+ <video
+ ref={videoRef}
+ key={currentVideoIndex}
+ autoPlay
+ muted
+ playsInline
+ onEnded={handleVideoEnd}
+ className="absolute inset-0 w-full h-full object-cover"
+ >
+ <source src={videos[currentVideoIndex]} type="video/mp4" />
+ </video>
+
+ {/* 오버레이 */}
+ <div className={cn('absolute inset-0 bg-black/40', overlayClassName)}></div>
+
+ {/* 캐시 상태 표시 (개발용) */}
+ {showCacheStatus && cachedVideos > 0 && (
+ <div className="absolute top-4 right-4 z-10 bg-black/70 text-white text-xs px-3 py-2 rounded-lg backdrop-blur-sm">
+ <div className="flex items-center space-x-2">
+ <span>
+ Cached: {cachedVideos}/{videos.length}
+ </span>
+ </div>
+ </div>
+ )}
+
+ {/* 비디오 인디케이터 */}
+ {showIndicators && isVideoLoaded && (
+ <div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-10 flex justify-center space-x-2">
+ {videos.map((_, index) => (
+ <button
+ key={index}
+ onClick={() => handleIndicatorClick(index)}
+ className={cn(
+ 'h-2 rounded-full transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-white/50',
+ index === currentVideoIndex
+ ? 'bg-white w-8'
+ : 'bg-white/50 hover:bg-white/75 w-2'
+ )}
+ aria-label={`비디오 ${index + 1}로 이동`}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx
index 51d54531..fae5b8c9 100644
--- a/components/login/login-form.tsx
+++ b/components/login/login-form.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
-import { Ship, InfoIcon, GlobeIcon, ChevronDownIcon, ArrowLeft } from "lucide-react";
+import { InfoIcon, GlobeIcon, ChevronDownIcon, ArrowLeft } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem } from "@/components/ui/dropdown-menu"
import { useTranslation } from '@/i18n/client'
@@ -21,6 +21,7 @@ import {
import { requestPasswordResetAction } from "@/lib/users/auth/partners-auth";
import { checkEmailAndStartAuth, resendEmailOtp } from "@/lib/users/auth/email-auth";
import Loading from "../common/loading/loading";
+import { VideoBackground } from "@/components/common/video-background";
// 배경 영상 목록
const BACKGROUND_VIDEOS = [
@@ -44,9 +45,6 @@ export function LoginForm() {
const { toast } = useToast();
const { data: session, status } = useSession();
- // 배경 영상 상태
- const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
-
// 상태 관리
const [isFirstAuthLoading, setIsFirstAuthLoading] = useState(false);
const [showForgotPassword, setShowForgotPassword] = useState(false);
@@ -83,14 +81,11 @@ export function LoginForm() {
// 호스트명 확인 상태 추가
const [isDataRoomHost, setIsDataRoomHost] = useState(false);
- // 컴포넌트 마운트 시 호스트명 확인 및 랜덤 비디오 선택
+ // 컴포넌트 마운트 시 호스트명 확인
useEffect(() => {
if (typeof window !== 'undefined') {
const hostname = window.location.hostname;
setIsDataRoomHost(hostname.includes('shidataroom'));
-
- // 랜덤한 비디오 인덱스 선택
- setCurrentVideoIndex(Math.floor(Math.random() * BACKGROUND_VIDEOS.length));
}
}, []);
@@ -168,11 +163,6 @@ export function LoginForm() {
router.push(`/${lng}/partners/repository`);
};
- // 비디오 종료 시 다음 비디오로 전환
- const handleVideoEnd = () => {
- setCurrentVideoIndex((prevIndex) => (prevIndex + 1) % BACKGROUND_VIDEOS.length);
- };
-
// MFA 카운트다운 효과
useEffect(() => {
if (mfaCountdown > 0) {
@@ -576,47 +566,59 @@ export function LoginForm() {
}
return (
- <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
- {/* Left Content */}
- <div className="flex flex-col w-full h-screen lg:p-2">
- {/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */}
- <div className="flex items-center justify-between">
- <div className="flex items-center space-x-2">
- <Ship className="w-4 h-4" />
- <span className="text-md font-bold">{isDataRoomHost ? "Data Room" : "eVCP"}</span>
+ <div className="relative flex h-screen w-full items-center justify-start overflow-hidden">
+ {/* 전체 화면 배경 영상 */}
+ <VideoBackground
+ videos={BACKGROUND_VIDEOS}
+ overlayClassName="bg-black/50"
+ showCacheStatus={process.env.NODE_ENV === 'development'}
+ />
+
+ {/* 로그인 카드 */}
+ <div className="relative z-20 w-full max-w-md ml-8 md:ml-16 lg:ml-24">
+ {/* 불투명한 카드 배경 */}
+ <div className="bg-card rounded-lg shadow-2xl overflow-hidden border border-border">
+ {/* 헤더 */}
+ <div className="px-6 py-4 border-b bg-gradient-to-r from-blue-600 to-blue-700">
+ <div className="flex items-center justify-between text-white">
+ <div className="flex items-center space-x-2">
+ {/* <Ship className="w-5 h-5" /> */}
+ <span className="text-lg font-bold">{isDataRoomHost ? "Data Room" : "eVCP"}</span>
+ </div>
+ {!isDataRoomHost && (
+ <Link
+ href="/partners/repository"
+ className={cn(
+ buttonVariants({ variant: "ghost" }),
+ "text-white hover:bg-white/20 hover:text-white"
+ )}
+ >
+ <InfoIcon className="w-4 h-4 mr-1" />
+ {t('registerVendor')}
+ </Link>
+ )}
+ </div>
</div>
- {!isDataRoomHost && (
- <Link
- href="/partners/repository"
- className={cn(buttonVariants({ variant: "ghost" }))}
- >
- <InfoIcon className="w-4 h-4 mr-1" />
- {t('registerVendor')}
- </Link>
- )}
- </div>
- {/* Content section that occupies remaining space, centered vertically */}
- <div className="flex-1 flex items-center justify-center">
- <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]">
- <div className="p-6 md:p-8">
- <div className="flex flex-col gap-6">
- {/* Header */}
- <div className="flex flex-col items-center text-center">
+ {/* 로그인 폼 컨텐츠 */}
+ <div className="p-8">
+ <div className="flex flex-col gap-6">
+ {/* Header */}
+ <div className="flex flex-col items-center text-center">
{!showMfaForm ? (
<>
- <h1 className="text-2xl font-bold">{isDataRoomHost ? t('loginMessageDataRoom') :t('loginMessage')}</h1>
+ <h1 className="text-2xl font-bold text-foreground">{isDataRoomHost ? t('loginMessageDataRoom') :t('loginMessage')}</h1>
<p className="text-xs text-muted-foreground mt-2">
{isDataRoomHost?t('loginDescriptionDataRoom') :t('loginDescription')}
</p>
</>
) : (
<>
- <div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-100 mb-4">
+ <div className="flex items-center justify-center w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 mb-4">
{mfaType === 'email' ? '📧' : '🔐'}
</div>
- <h1 className="text-2xl font-bold">
+ <h1 className="text-2xl font-bold text-foreground">
{mfaType === 'email' ? t('emailVerification') : t('smsVerification')}
</h1>
<p className="text-sm text-muted-foreground mt-2">
@@ -674,7 +676,7 @@ export function LoginForm() {
variant="ghost"
size="sm"
onClick={handleBackToLogin}
- className="text-blue-600 hover:text-blue-800"
+ className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
<ArrowLeft className="w-4 h-4 mr-1" />
{t('backToLogin')}
@@ -682,9 +684,9 @@ export function LoginForm() {
</div>
{/* 이메일 표시 */}
- <div className="bg-gray-50 p-3 rounded-lg">
- <p className="text-sm text-gray-600 mb-1">{t('loginEmail')}</p>
- <p className="text-sm font-medium text-gray-900">{emailInput}</p>
+ <div className="bg-muted p-3 rounded-lg">
+ <p className="text-sm text-muted-foreground mb-1">{t('loginEmail')}</p>
+ <p className="text-sm font-medium text-foreground">{emailInput}</p>
</div>
{/* 패스워드 입력 폼 */}
@@ -722,7 +724,7 @@ export function LoginForm() {
<Button
type="button"
variant="link"
- className="text-blue-600 hover:text-blue-800 text-sm"
+ className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 text-sm"
onClick={goToVendorRegistration}
>
{t('newVendor')}
@@ -734,7 +736,7 @@ export function LoginForm() {
<Button
type="button"
variant="link"
- className="text-blue-600 hover:text-blue-800 text-sm"
+ className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 text-sm"
onClick={() => setShowForgotPassword(true)}
>
{t('forgotPassword')}
@@ -752,7 +754,7 @@ export function LoginForm() {
variant="ghost"
size="sm"
onClick={handleBackToLogin}
- className="text-blue-600 hover:text-blue-800"
+ className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
<ArrowLeft className="w-4 h-4 mr-1" />
{t('backToLogin')}
@@ -760,11 +762,11 @@ export function LoginForm() {
</div>
{/* OTP 재전송 섹션 (SMS/Email) */}
- <div className="bg-gray-50 p-4 rounded-lg">
- <h3 className="text-sm font-medium text-gray-900 mb-2">
+ <div className="bg-muted p-4 rounded-lg">
+ <h3 className="text-sm font-medium text-foreground mb-2">
{t('resendCode')}
</h3>
- <p className="text-xs text-gray-600 mb-3">
+ <p className="text-xs text-muted-foreground mb-3">
{mfaType === 'email'
? t('didNotReceiveEmail')
: t('didNotReceiveCode')}
@@ -797,7 +799,7 @@ export function LoginForm() {
<form onSubmit={handleMfaSubmit} className="space-y-6">
<div className="space-y-4">
<div className="text-center">
- <label className="block text-sm font-medium text-gray-700 mb-3">
+ <label className="block text-sm font-medium text-foreground mb-3">
{t('enterSixDigitCode')}
</label>
<div className="flex justify-center">
@@ -830,16 +832,16 @@ export function LoginForm() {
</form>
{/* 도움말 */}
- <div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200">
+ <div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div className="flex">
<div className="flex-shrink-0">
⚠️
</div>
<div className="ml-2">
- <h4 className="text-xs font-medium text-yellow-800">
+ <h4 className="text-xs font-medium text-yellow-800 dark:text-yellow-300">
{t('didNotReceiveCode')}
</h4>
- <div className="mt-1 text-xs text-yellow-700">
+ <div className="mt-1 text-xs text-yellow-700 dark:text-yellow-400">
<ul className="list-disc list-inside space-y-1">
<li>{t('checkPhoneNumber')}</li>
<li>{t('checkSpamFolder')}</li>
@@ -854,22 +856,22 @@ export function LoginForm() {
{/* 비밀번호 재설정 다이얼로그 */}
{showForgotPassword && !showMfaForm && (
- <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
- <div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
+ <div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-card rounded-lg p-6 w-full max-w-md mx-4 border border-border">
<div className="flex justify-between items-center mb-4">
- <h3 className="text-lg font-semibold">{t('resetPassword')}</h3>
+ <h3 className="text-lg font-semibold text-foreground">{t('resetPassword')}</h3>
<button
onClick={() => {
setShowForgotPassword(false);
}}
- className="text-gray-400 hover:text-gray-600"
+ className="text-muted-foreground hover:text-foreground"
>
</button>
</div>
<form action={passwordResetAction} className="space-y-4">
<div>
- <p className="text-sm text-gray-600 mb-3">
+ <p className="text-sm text-muted-foreground mb-3">
{t('resetDescription')}
</p>
<Input
@@ -929,60 +931,21 @@ export function LoginForm() {
</DropdownMenu>
</div>
)}
- </div>
- </div>
- {/* Terms - MFA 화면에서는 숨김 */}
- {!showMfaForm && (
- <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary">
- {t("agreement")}{" "}
- <Link
- href={`/${lng}/privacy`}
- className="underline underline-offset-4 hover:text-primary"
- >
- {t("privacyPolicy")}
- </Link>
- </div>
- )}
- </div>
- </div>
- </div>
-
- {/* Right BG 영상 영역 */}
- <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex overflow-hidden">
- <div className="absolute inset-0">
- <video
- key={currentVideoIndex}
- autoPlay
- muted
- onEnded={handleVideoEnd}
- className="w-full h-full object-cover"
- playsInline
- >
- <source src={BACKGROUND_VIDEOS[currentVideoIndex]} type="video/mp4" />
- </video>
- {/* 어두운 오버레이 */}
- <div className="absolute inset-0 bg-black/30"></div>
- </div>
- <div className="relative z-10 mt-auto">
- <blockquote className="space-y-2 backdrop-blur-sm bg-black/20 p-4 rounded-lg">
- <p className="text-sm font-medium drop-shadow-lg">&ldquo;{t("blockquote")}&rdquo;</p>
- </blockquote>
- </div>
- {/* 비디오 인디케이터 */}
- <div className="relative z-10 flex justify-center space-x-2 mb-4">
- {BACKGROUND_VIDEOS.map((_, index) => (
- <div
- key={index}
- className={cn(
- "w-2 h-2 rounded-full transition-all duration-300",
- index === currentVideoIndex
- ? "bg-white w-8"
- : "bg-white/50 hover:bg-white/75 cursor-pointer"
+ {/* Terms - MFA 화면에서는 숨김 */}
+ {!showMfaForm && (
+ <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary mt-4">
+ {t("agreement")}{" "}
+ <Link
+ href={`/${lng}/privacy`}
+ className="underline underline-offset-4 hover:text-primary"
+ >
+ {t("privacyPolicy")}
+ </Link>
+ </div>
)}
- onClick={() => setCurrentVideoIndex(index)}
- />
- ))}
+ </div>
+ </div>
</div>
</div>
</div>