|
|
| import React, { useState, useEffect, useRef, useCallback } from 'react'; |
| import { Download, Maximize2, X, Grid, Loader2, Video, Settings, Sparkles, Share2, ArrowLeftRight, PlayCircle, CheckCircle, Info, Quote, FolderDown } from 'lucide-react'; |
| import { GeneratedResult, WeddingStyle, Language } from '../types'; |
| import { STYLES } from './StyleSelector'; |
| import { TRANSLATIONS } from '../constants/translations'; |
| import JSZip from 'jszip'; |
|
|
| interface ResultViewerProps { |
| originalImages: string[]; |
| results: Record<string, GeneratedResult>; |
| initialSelectedId?: string; |
| onReset: () => void; |
| activeFilter?: string; |
| blurAmount?: number; |
| language: Language; |
| onBookClick: () => void; |
| onShareClick: () => void; |
| } |
|
|
| export const ResultViewer: React.FC<ResultViewerProps> = ({ |
| originalImages, results, initialSelectedId, onReset, language, onBookClick, onShareClick |
| }) => { |
| const resultKeys = Object.keys(results); |
| const [activeId, setActiveId] = useState<string | null>(initialSelectedId || resultKeys[0]); |
| const [compareMode, setCompareMode] = useState(false); |
| const [sliderPosition, setSliderPosition] = useState(50); |
| const [isZipping, setIsZipping] = useState(false); |
| const containerRef = useRef<HTMLDivElement>(null); |
| const isDragging = useRef(false); |
| const t = TRANSLATIONS[language]; |
|
|
| |
| useEffect(() => { |
| if (!activeId && resultKeys.length > 0) { |
| setActiveId(resultKeys[0]); |
| } |
| }, [results, activeId, resultKeys]); |
|
|
| const activeResult = activeId ? results[activeId] : null; |
|
|
| const getFounderAdvice = (styleId: string) => { |
| const advices: Record<string, string> = { |
| 'korean': language === 'zh' ? "韩式风格最能体现您的温婉,建议搭配简约珍珠项链。" : "Korean style best highlights your elegance. Pair with pearls.", |
| 'chinese': language === 'zh' ? "大红喜嫁色非常衬您的肤色,建议拍摄时多一些互动抓拍。" : "The wedding red really complements your skin tone. Go for candid shots.", |
| 'cinematic': language === 'zh' ? "这种电影感非常适合您的五官,光影层次感十足。" : "This cinematic vibe suits your features perfectly, full of depth." |
| }; |
| if (styleId.startsWith('blend')) { |
| return language === 'zh' ? "混搭风格展现了您的独特个性,建议妆造保持自然通透。" : "Hybrid styles showcase your unique personality. Keep the makeup natural and sheer."; |
| } |
| return advices[styleId] || (language === 'zh' ? "这一套造型非常惊艳,完美展现了您的气质。" : "This look is stunning and fits your aura perfectly."); |
| }; |
|
|
| const updateSlider = (clientX: number) => { |
| if (!containerRef.current) return; |
| const rect = containerRef.current.getBoundingClientRect(); |
| const pos = ((clientX - rect.left) / rect.width) * 100; |
| setSliderPosition(Math.max(0, Math.min(100, pos))); |
| }; |
|
|
| const handleMouseMove = (e: any) => { |
| if (!isDragging.current) return; |
| const clientX = e.clientX || e.touches?.[0]?.clientX; |
| updateSlider(clientX); |
| }; |
|
|
| const handleDownloadAll = async () => { |
| if (Object.keys(results).length === 0) return; |
| setIsZipping(true); |
| try { |
| const zip = new JSZip(); |
| const timestamp = new Date().getTime(); |
| |
| const promises = Object.entries(results).map(async ([id, res], index) => { |
| const resultItem = res as GeneratedResult; |
| |
| const base64Data = resultItem.imageUrl.split(',')[1]; |
| const fileName = `RomanticLife_${id}_${index + 1}.png`; |
| zip.file(fileName, base64Data, { base64: true }); |
| }); |
|
|
| await Promise.all(promises); |
| const content = await zip.generateAsync({ type: "blob" }); |
| const url = URL.createObjectURL(content); |
| const link = document.createElement('a'); |
| link.href = url; |
| link.download = `RomanticLife_Wedding_Album_${timestamp}.zip`; |
| document.body.appendChild(link); |
| link.click(); |
| document.body.removeChild(link); |
| URL.revokeObjectURL(url); |
| } catch (error) { |
| console.error("ZIP Generation failed:", error); |
| alert("Failed to generate ZIP. Please try individual downloads."); |
| } finally { |
| setIsZipping(false); |
| } |
| }; |
|
|
| if (!activeResult) return null; |
|
|
| return ( |
| <div className="flex flex-col h-full gap-4 animate-fade-in"> |
| {/* 沉浸式展示区 */} |
| <div |
| ref={containerRef} |
| className="relative flex-1 bg-zinc-900 rounded-3xl border border-zinc-800 overflow-hidden shadow-2xl min-h-[500px]" |
| onMouseDown={() => { isDragging.current = true; }} |
| onMouseUp={() => { isDragging.current = false; }} |
| onMouseMove={handleMouseMove} |
| onTouchMove={handleMouseMove} |
| onTouchStart={() => { isDragging.current = true; }} |
| onTouchEnd={() => { isDragging.current = false; }} |
| > |
| <img src={originalImages[0]} className="absolute inset-0 w-full h-full object-contain p-4 opacity-50 blur-sm" alt="original" /> |
| |
| <img |
| src={activeResult.imageUrl} |
| className="absolute inset-0 w-full h-full object-contain p-4 z-10" |
| alt="result" |
| style={compareMode ? { clipPath: `inset(0 0 0 ${sliderPosition}%)` } : {}} |
| /> |
| |
| {compareMode && ( |
| <> |
| <img |
| src={originalImages[0]} |
| className="absolute inset-0 w-full h-full object-contain p-4 z-10" |
| alt="original-overlay" |
| style={{ clipPath: `inset(0 ${100 - sliderPosition}% 0 0)` }} |
| /> |
| <div className="absolute top-0 bottom-0 z-20 w-1 bg-white/50 cursor-ew-resize" style={{ left: `${sliderPosition}%` }}> |
| <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 bg-white rounded-full shadow-lg flex items-center justify-center text-zinc-900"> |
| <ArrowLeftRight className="w-4 h-4" /> |
| </div> |
| </div> |
| </> |
| )} |
| |
| <div className="absolute bottom-6 left-6 right-6 z-30 flex justify-between items-end pointer-events-none"> |
| <div className="bg-black/40 backdrop-blur-md p-4 rounded-2xl border border-white/10 max-w-[70%] pointer-events-auto"> |
| <div className="flex items-center gap-2 mb-1 text-rose-400"> |
| <Quote className="w-4 h-4" /> |
| <span className="text-[10px] font-bold uppercase tracking-widest">{t.aboutUsBtn}</span> |
| </div> |
| <p className="text-white text-xs leading-relaxed italic"> |
| "{getFounderAdvice(activeId!)}" |
| </p> |
| </div> |
| |
| <div className="flex flex-col gap-2 pointer-events-auto"> |
| <button |
| onClick={() => setCompareMode(!compareMode)} |
| className={`p-3 rounded-full backdrop-blur-md transition-all ${compareMode ? 'bg-rose-500 text-white' : 'bg-white/10 text-white border border-white/20'}`} |
| > |
| <ArrowLeftRight className="w-5 h-5" /> |
| </button> |
| <button onClick={onShareClick} className="p-3 bg-white/10 backdrop-blur-md text-white rounded-full border border-white/20"> |
| <Share2 className="w-5 h-5" /> |
| </button> |
| </div> |
| </div> |
| |
| <div className="absolute top-6 left-6 z-30 pointer-events-none opacity-30"> |
| <p className="text-white font-serif tracking-widest text-lg uppercase">{t.watermark}</p> |
| </div> |
| </div> |
|
|
| {} |
| <div className="bg-white rounded-2xl p-4 shadow-sm border border-gray-100 flex flex-col sm:flex-row items-center justify-between gap-4"> |
| <div className="flex -space-x-2 overflow-hidden py-1"> |
| {Object.keys(results).map(id => ( |
| <button |
| key={id} |
| onClick={() => setActiveId(id)} |
| className={`w-12 h-12 rounded-lg border-2 transition-all overflow-hidden shrink-0 ${activeId === id ? 'border-rose-500 scale-110 z-10 shadow-lg' : 'border-transparent opacity-50 hover:opacity-100'}`} |
| > |
| <img src={(results[id] as GeneratedResult).imageUrl} className="w-full h-full object-cover" alt="thumb" /> |
| </button> |
| ))} |
| </div> |
|
|
| <div className="flex gap-2 w-full sm:w-auto"> |
| <button |
| onClick={handleDownloadAll} |
| disabled={isZipping || resultKeys.length === 0} |
| className="flex-1 sm:flex-none px-4 py-2.5 bg-gray-100 text-gray-800 rounded-xl text-xs font-black transition-all flex items-center justify-center gap-2 hover:bg-gray-200 active:scale-95 disabled:opacity-50" |
| > |
| {isZipping ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderDown className="w-4 h-4" />} |
| <span>{isZipping ? "Packing..." : (t.zipBtn || "Download All ZIP")}</span> |
| </button> |
| <button |
| onClick={onBookClick} |
| className="flex-1 sm:flex-none px-6 py-2.5 bg-rose-600 text-white rounded-xl text-xs font-black hover:bg-rose-700 shadow-lg shadow-rose-200 transition-all flex items-center justify-center gap-2 active:scale-95" |
| > |
| <CheckCircle className="w-4 h-4" /> |
| <span>{t.bookStyle}</span> |
| </button> |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|