Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
408
frontend/src/components/FilmStripSlider.tsx
Normal file
408
frontend/src/components/FilmStripSlider.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
'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 } 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;
|
||||
}
|
||||
|
||||
// 이미지 URL 변환 (로컬 경로는 백엔드 URL 추가)
|
||||
const getImageUrl = (url: string): string => {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
// 로컬 경로인 경우 백엔드 URL 추가
|
||||
return `http://localhost:8000${url}`;
|
||||
};
|
||||
|
||||
// Helper to get localized title/subtitle based on language
|
||||
function getLocalizedText(banner: HeroBanner, field: 'title' | 'subtitle', language: Language): string {
|
||||
// Public API returns localized single field (title, subtitle)
|
||||
const directField = banner[field as keyof HeroBanner] as string | undefined;
|
||||
if (directField) {
|
||||
return directField;
|
||||
}
|
||||
// Admin API returns multi-language fields (title_ko, title_en, etc.)
|
||||
const langKey = `${field}_${language}` as keyof HeroBanner;
|
||||
const enKey = `${field}_en` as keyof HeroBanner;
|
||||
return (banner[langKey] as string) || (banner[enKey] as string) || '';
|
||||
}
|
||||
|
||||
function BannerCard({ banner, width, height }: BannerCardProps) {
|
||||
const { language } = useLanguageStore();
|
||||
const imageUrl = getImageUrl(banner.image_url);
|
||||
|
||||
// 언어별 제목과 부제목 가져오기
|
||||
const title = getLocalizedText(banner, 'title', language);
|
||||
const subtitle = getLocalizedText(banner, 'subtitle', 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;
|
||||
}
|
||||
Reference in New Issue
Block a user