ai / components /ResultViewer.tsx
Lianjx's picture
Upload 75 files
8fb4cca verified
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];
// Sync activeId if results update
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;
// Extract base64 part
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>
);
};