Apply translateCarName to banner titles and subtitles to show translated text instead of Korean when hovering over banners. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useRef } from 'react';
|
|
import { motion, useAnimationControls } from 'framer-motion';
|
|
import Image from 'next/image';
|
|
import Link from 'next/link';
|
|
import { HeroBanner, HeroBannerSettings } from '@/types';
|
|
import { useLanguageStore, Language, translateCarName } from '@/lib/i18n';
|
|
|
|
interface FilmStripSliderProps {
|
|
banners: HeroBanner[];
|
|
settings?: HeroBannerSettings;
|
|
}
|
|
|
|
// 기본 설정
|
|
const defaultSettings: HeroBannerSettings = {
|
|
id: 0,
|
|
slide_interval: 3000,
|
|
animation_type: 'film-strip',
|
|
image_width: 500,
|
|
image_height: 300,
|
|
auto_play: true,
|
|
};
|
|
|
|
// 샘플 배너 데이터 (API 데이터가 없을 때 사용)
|
|
const sampleBanners: HeroBanner[] = [
|
|
{
|
|
id: 1,
|
|
title_en: 'Hyundai Sonata 2023',
|
|
title_ko: '현대 소나타 2023',
|
|
title_mn: 'Хёндай Соната 2023',
|
|
subtitle_en: 'Premium Sedan',
|
|
subtitle_ko: '프리미엄 세단',
|
|
subtitle_mn: 'Премиум седан',
|
|
image_url: 'https://images.unsplash.com/photo-1605559424843-9e4c228bf1c2?w=500&h=300&fit=crop',
|
|
link_url: '/cars',
|
|
is_active: true,
|
|
display_order: 0,
|
|
},
|
|
{
|
|
id: 2,
|
|
title_en: 'Kia Sportage 2022',
|
|
title_ko: '기아 스포티지 2022',
|
|
title_mn: 'Киа Спортаж 2022',
|
|
subtitle_en: 'Compact SUV',
|
|
subtitle_ko: '컴팩트 SUV',
|
|
subtitle_mn: 'Компакт SUV',
|
|
image_url: 'https://images.unsplash.com/photo-1494976388531-d1058494cdd8?w=500&h=300&fit=crop',
|
|
link_url: '/cars',
|
|
is_active: true,
|
|
display_order: 1,
|
|
},
|
|
{
|
|
id: 3,
|
|
title_en: 'Genesis G80 2023',
|
|
title_ko: '제네시스 G80 2023',
|
|
title_mn: 'Женезис G80 2023',
|
|
subtitle_en: 'Luxury Sedan',
|
|
subtitle_ko: '럭셔리 세단',
|
|
subtitle_mn: 'Люкс седан',
|
|
image_url: 'https://images.unsplash.com/photo-1503376780353-7e6692767b70?w=500&h=300&fit=crop',
|
|
link_url: '/cars',
|
|
is_active: true,
|
|
display_order: 2,
|
|
},
|
|
{
|
|
id: 4,
|
|
title_en: 'Hyundai Tucson 2023',
|
|
title_ko: '현대 투싼 2023',
|
|
title_mn: 'Хёндай Туксон 2023',
|
|
subtitle_en: 'Family SUV',
|
|
subtitle_ko: '패밀리 SUV',
|
|
subtitle_mn: 'Гэр бүлийн SUV',
|
|
image_url: 'https://images.unsplash.com/photo-1552519507-da3b142c6e3d?w=500&h=300&fit=crop',
|
|
link_url: '/cars',
|
|
is_active: true,
|
|
display_order: 3,
|
|
},
|
|
{
|
|
id: 5,
|
|
title_en: 'Kia EV6 2023',
|
|
title_ko: '기아 EV6 2023',
|
|
title_mn: 'Киа EV6 2023',
|
|
subtitle_en: 'Electric Vehicle',
|
|
subtitle_ko: '전기차',
|
|
subtitle_mn: 'Цахилгаан машин',
|
|
image_url: 'https://images.unsplash.com/photo-1617788138017-80ad40651399?w=500&h=300&fit=crop',
|
|
link_url: '/cars',
|
|
is_active: true,
|
|
display_order: 4,
|
|
},
|
|
];
|
|
|
|
export default function FilmStripSlider({ banners, settings }: FilmStripSliderProps) {
|
|
const effectiveSettings = settings || defaultSettings;
|
|
const effectiveBanners = banners.length > 0 ? banners : sampleBanners;
|
|
|
|
// 무한 루프를 위해 배너를 3배로 복제
|
|
const duplicatedBanners = [...effectiveBanners, ...effectiveBanners, ...effectiveBanners];
|
|
|
|
const controls = useAnimationControls();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [isPaused, setIsPaused] = useState(false);
|
|
const currentXRef = useRef(0);
|
|
const animationStartTimeRef = useRef(0);
|
|
|
|
const imageWidth = effectiveSettings.image_width;
|
|
const imageHeight = effectiveSettings.image_height;
|
|
const gap = 16; // gap between images
|
|
const singleSetWidth = (imageWidth + gap) * effectiveBanners.length;
|
|
const totalDuration = effectiveBanners.length * (effectiveSettings.slide_interval / 1000);
|
|
|
|
// 무한 슬라이드 애니메이션
|
|
useEffect(() => {
|
|
if (!effectiveSettings.auto_play) return;
|
|
|
|
if (isPaused) {
|
|
// 일시정지 시 현재 위치 계산 및 저장
|
|
const elapsed = (Date.now() - animationStartTimeRef.current) / 1000;
|
|
const progress = (elapsed % totalDuration) / totalDuration;
|
|
currentXRef.current = -singleSetWidth * progress;
|
|
controls.stop();
|
|
return;
|
|
}
|
|
|
|
// 현재 위치에서 남은 거리 계산
|
|
const currentX = currentXRef.current;
|
|
const remainingDistance = -singleSetWidth - currentX;
|
|
const remainingDuration = (remainingDistance / -singleSetWidth) * totalDuration;
|
|
|
|
const animate = async () => {
|
|
// 먼저 남은 거리를 완료
|
|
if (remainingDistance < 0) {
|
|
await controls.start({
|
|
x: -singleSetWidth,
|
|
transition: {
|
|
duration: remainingDuration,
|
|
ease: 'linear',
|
|
},
|
|
});
|
|
}
|
|
|
|
// 그 후 무한 루프 시작
|
|
animationStartTimeRef.current = Date.now();
|
|
currentXRef.current = 0;
|
|
controls.set({ x: 0 });
|
|
|
|
await controls.start({
|
|
x: -singleSetWidth,
|
|
transition: {
|
|
duration: totalDuration,
|
|
ease: 'linear',
|
|
repeat: Infinity,
|
|
repeatType: 'loop',
|
|
},
|
|
});
|
|
};
|
|
|
|
// 현재 위치 설정 후 애니메이션 시작
|
|
controls.set({ x: currentX });
|
|
animationStartTimeRef.current = Date.now() - ((-currentX / singleSetWidth) * totalDuration * 1000);
|
|
animate();
|
|
|
|
return () => {
|
|
controls.stop();
|
|
};
|
|
}, [controls, effectiveBanners.length, singleSetWidth, effectiveSettings.auto_play, totalDuration, isPaused]);
|
|
|
|
const handleMouseEnter = () => {
|
|
setIsPaused(true);
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
setIsPaused(false);
|
|
};
|
|
|
|
// 현재 X 위치 계산
|
|
const getCurrentX = () => {
|
|
if (isPaused) {
|
|
return currentXRef.current;
|
|
}
|
|
const elapsed = (Date.now() - animationStartTimeRef.current) / 1000;
|
|
const progress = (elapsed % totalDuration) / totalDuration;
|
|
return -singleSetWidth * progress;
|
|
};
|
|
|
|
// 다음/이전 차량으로 빠르게 이동
|
|
const handleNext = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
const itemWidth = imageWidth + gap;
|
|
const currentX = getCurrentX();
|
|
|
|
// 현재 위치에서 다음 아이템 위치 계산
|
|
const currentItem = Math.floor(-currentX / itemWidth);
|
|
const nextX = -((currentItem + 1) * itemWidth);
|
|
|
|
// 범위 체크 (singleSetWidth를 넘으면 처음으로)
|
|
const targetX = nextX <= -singleSetWidth ? 0 : nextX;
|
|
|
|
// 애니메이션 중지 후 위치 설정
|
|
controls.stop();
|
|
currentXRef.current = targetX;
|
|
animationStartTimeRef.current = Date.now() - ((-targetX / singleSetWidth) * totalDuration * 1000);
|
|
|
|
// 부드러운 이동 애니메이션
|
|
await controls.start({
|
|
x: targetX,
|
|
transition: { duration: 0.3, ease: 'easeOut' }
|
|
});
|
|
|
|
// 일시정지 상태가 아니면 자동 재생 트리거
|
|
if (!isPaused) {
|
|
setIsPaused(true);
|
|
setTimeout(() => setIsPaused(false), 50);
|
|
}
|
|
};
|
|
|
|
const handlePrev = async (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
const itemWidth = imageWidth + gap;
|
|
const currentX = getCurrentX();
|
|
|
|
// 현재 위치에서 이전 아이템 위치 계산
|
|
const currentItem = Math.floor(-currentX / itemWidth);
|
|
const prevX = -((currentItem - 1) * itemWidth);
|
|
|
|
// 범위 체크 (0보다 크면 끝으로)
|
|
const targetX = prevX > 0 ? -(singleSetWidth - itemWidth) : prevX;
|
|
|
|
// 애니메이션 중지 후 위치 설정
|
|
controls.stop();
|
|
currentXRef.current = targetX;
|
|
animationStartTimeRef.current = Date.now() - ((-targetX / singleSetWidth) * totalDuration * 1000);
|
|
|
|
// 부드러운 이동 애니메이션
|
|
await controls.start({
|
|
x: targetX,
|
|
transition: { duration: 0.3, ease: 'easeOut' }
|
|
});
|
|
|
|
// 일시정지 상태가 아니면 자동 재생 트리거
|
|
if (!isPaused) {
|
|
setIsPaused(true);
|
|
setTimeout(() => setIsPaused(false), 50);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="relative w-full overflow-hidden bg-gradient-to-b from-gray-900 to-gray-800 py-8">
|
|
{/* Film strip effect - top perforation */}
|
|
<div className="absolute top-0 left-0 right-0 h-4 bg-gray-900 flex items-center justify-around">
|
|
{Array.from({ length: 30 }).map((_, i) => (
|
|
<div key={i} className="w-3 h-2 bg-gray-700 rounded-sm" />
|
|
))}
|
|
</div>
|
|
|
|
{/* Main slider container */}
|
|
<div
|
|
ref={containerRef}
|
|
className="relative overflow-hidden mx-auto"
|
|
style={{ height: imageHeight + 40 }}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
<motion.div
|
|
className="flex gap-4 absolute"
|
|
animate={controls}
|
|
style={{ paddingLeft: gap }}
|
|
>
|
|
{duplicatedBanners.map((banner, index) => (
|
|
<BannerCard
|
|
key={`${banner.id}-${index}`}
|
|
banner={banner}
|
|
width={imageWidth}
|
|
height={imageHeight}
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Film strip effect - bottom perforation */}
|
|
<div className="absolute bottom-0 left-0 right-0 h-4 bg-gray-900 flex items-center justify-around">
|
|
{Array.from({ length: 30 }).map((_, i) => (
|
|
<div key={i} className="w-3 h-2 bg-gray-700 rounded-sm" />
|
|
))}
|
|
</div>
|
|
|
|
{/* Gradient overlays for fade effect at edges */}
|
|
<div className="absolute top-4 bottom-4 left-0 w-24 bg-gradient-to-r from-gray-900 to-transparent pointer-events-none z-10" />
|
|
<div className="absolute top-4 bottom-4 right-0 w-24 bg-gradient-to-l from-gray-900 to-transparent pointer-events-none z-10" />
|
|
|
|
{/* Navigation Arrows */}
|
|
<button
|
|
type="button"
|
|
onClick={handlePrev}
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 z-20 bg-white/20 hover:bg-white/40 text-white rounded-full p-3 backdrop-blur-sm transition-all duration-200 hover:scale-110"
|
|
aria-label="Previous"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleNext}
|
|
className="absolute right-4 top-1/2 -translate-y-1/2 z-20 bg-white/20 hover:bg-white/40 text-white rounded-full p-3 backdrop-blur-sm transition-all duration-200 hover:scale-110"
|
|
aria-label="Next"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BannerCardProps {
|
|
banner: HeroBanner;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
|
|
|
// 이미지 URL 변환 (로컬 경로는 백엔드 URL 추가)
|
|
const getImageUrl = (url: string): string => {
|
|
if (!url) return '';
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
return url;
|
|
}
|
|
// 로컬 경로인 경우 백엔드 URL 추가
|
|
return `${API_BASE_URL}${url}`;
|
|
};
|
|
|
|
// Helper to get localized title/subtitle based on language
|
|
function getLocalizedText(banner: HeroBanner, field: 'title' | 'subtitle', language: Language): string {
|
|
// 1. 먼저 선택된 언어의 필드 확인 (title_mn, title_en, etc.)
|
|
const langKey = `${field}_${language}` as keyof HeroBanner;
|
|
const langValue = banner[langKey] as string | undefined;
|
|
if (langValue) {
|
|
return langValue;
|
|
}
|
|
|
|
// 2. 영어 폴백
|
|
const enKey = `${field}_en` as keyof HeroBanner;
|
|
const enValue = banner[enKey] as string | undefined;
|
|
if (enValue) {
|
|
return enValue;
|
|
}
|
|
|
|
// 3. 마지막으로 직접 필드 (API가 단일 필드로 반환하는 경우)
|
|
const directField = banner[field as keyof HeroBanner] as string | undefined;
|
|
return directField || '';
|
|
}
|
|
|
|
function BannerCard({ banner, width, height }: BannerCardProps) {
|
|
const { language } = useLanguageStore();
|
|
const imageUrl = getImageUrl(banner.image_url);
|
|
|
|
// 언어별 제목과 부제목 가져오기 (한국어인 경우 번역 적용)
|
|
const rawTitle = getLocalizedText(banner, 'title', language);
|
|
const rawSubtitle = getLocalizedText(banner, 'subtitle', language);
|
|
|
|
// 한국어 차량명을 선택한 언어로 번역
|
|
const title = translateCarName(rawTitle, language);
|
|
const subtitle = translateCarName(rawSubtitle, language);
|
|
|
|
const content = (
|
|
<div
|
|
className="relative group cursor-pointer flex-shrink-0 rounded-lg overflow-hidden shadow-2xl transform transition-transform duration-300 hover:scale-105"
|
|
style={{ width, height }}
|
|
>
|
|
{/* Image */}
|
|
<div className="relative w-full h-full">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={imageUrl}
|
|
alt={title || 'Car image'}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
|
|
{/* Overlay on hover */}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
|
|
|
{/* Text overlay */}
|
|
<div className="absolute bottom-0 left-0 right-0 p-4 text-white transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
|
|
{title && (
|
|
<h3 className="text-lg font-bold truncate">{title}</h3>
|
|
)}
|
|
{subtitle && (
|
|
<p className="text-sm text-gray-300 truncate">{subtitle}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Film frame border effect */}
|
|
<div className="absolute inset-0 border-4 border-gray-800 rounded-lg pointer-events-none" />
|
|
</div>
|
|
);
|
|
|
|
if (banner.link_url) {
|
|
return (
|
|
<Link href={banner.link_url}>
|
|
{content}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
if (banner.car_id) {
|
|
return (
|
|
<Link href={`/cars/${banner.car_id}`}>
|
|
{content}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
return content;
|
|
}
|