| |
|
| | import React, { useRef, useState, useMemo } from 'react'; |
| | import { WeddingStyle, Language, Resolution } from '../types'; |
| | import { TRANSLATIONS } from '../constants/translations'; |
| | import { useUserStore } from '../store'; |
| | import { |
| | Heart, Crown, Leaf, Sparkles, Film, Sun, Star, Aperture, |
| | Upload, Cpu, Clock, Lock, Search, Check, Loader2, GitMerge, |
| | Dice5, Wand2 |
| | } from 'lucide-react'; |
| |
|
| | interface StyleSelectorProps { |
| | selectedStyle: WeddingStyle | null; |
| | selectedStyles?: WeddingStyle[]; |
| | onSelect: (style: WeddingStyle) => void; |
| | onCustomSelect: (image: string) => void; |
| | onLuckySelect: () => void; |
| | onSlashClick: (style: WeddingStyle) => void; |
| | disabled: boolean; |
| | language: Language; |
| | recommendedStyleIds?: string[]; |
| | isVipUnlocked?: boolean; |
| | isLoading?: boolean; |
| | resolution: Resolution; |
| | onResolutionChange: (res: Resolution) => void; |
| | onBlendSelect?: (styles: WeddingStyle[]) => void; |
| | } |
| |
|
| | const LazyStyleBackground = ({ src, colorClass }: { src?: string, colorClass: string }) => { |
| | const [loaded, setLoaded] = useState(false); |
| | const [error, setError] = useState(false); |
| | if (!src || error) { |
| | return <div className={`absolute inset-0 opacity-40 ${colorClass} transition-opacity group-hover:opacity-60`} aria-hidden="true" />; |
| | } |
| | return ( |
| | <> |
| | {!loaded && ( |
| | <div className={`absolute inset-0 ${colorClass} flex items-center justify-center z-10`} aria-hidden="true"> |
| | <Loader2 className="w-5 h-5 text-gray-400/50 animate-spin" /> |
| | </div> |
| | )} |
| | <img |
| | src={src} |
| | alt="" |
| | loading="lazy" |
| | onLoad={() => setLoaded(true)} |
| | onError={() => setError(true)} |
| | className={`absolute inset-0 w-full h-full object-cover transition-all duration-700 ${loaded ? 'opacity-30 group-hover:opacity-50 scale-100' : 'opacity-0 scale-110'}`} |
| | aria-hidden="true" |
| | /> |
| | </> |
| | ); |
| | }; |
| |
|
| | export const STYLES: WeddingStyle[] = [ |
| | { |
| | id: 'korean', |
| | name: '韩式 (Korean)', |
| | prompt: 'Korean wedding photography style, minimalist, clean solid background, soft studio lighting, elegant white mermaid dress, simple veil, romantic and sweet atmosphere, flawless makeup, K-drama aesthetic.', |
| | promptKeywords: ['minimalist', 'sweet', 'soft lighting', 'mermaid gown', 'K-drama', 'clean background', 'elegant'], |
| | description: 'Minimalist, sweet, and elegant.', |
| | coverColor: 'bg-rose-50', |
| | previewImage: 'https://images.unsplash.com/photo-1520854221256-17451cc330e7?auto=format&fit=crop&w=400&q=60', |
| | icon: <Heart className="w-5 h-5 text-rose-400" aria-hidden="true" />, |
| | tags: ['hot'], |
| | isLocked: true |
| | }, |
| | { |
| | id: 'chinese', |
| | name: '中国风 (Chinese)', |
| | prompt: 'Modern Chinese wedding style, luxurious red embroidery, traditional elements mixed with modern aesthetics, fan, porcelain, red background, festive and grand.', |
| | promptKeywords: ['red', 'gold embroidery', 'oriental', 'traditional', 'festive', 'luxury', 'porcelain aesthetics'], |
| | description: 'Modern red aesthetics and embroidery.', |
| | coverColor: 'bg-red-50', |
| | previewImage: 'https://images.unsplash.com/photo-1588667822949-a034d612df08?auto=format&fit=crop&w=400&q=60', |
| | icon: <Leaf className="w-5 h-5 text-red-600" aria-hidden="true" />, |
| | tags: ['hot'], |
| | isLocked: true |
| | }, |
| | { |
| | id: 'cinematic', |
| | name: '电影感 (Cinematic)', |
| | prompt: 'Cinematic movie still, Wong Kar-wai style, moody lighting, strong shadows, emotional storytelling, color graded, wide aspect ratio composition feeling.', |
| | promptKeywords: ['moody', 'high contrast', 'storytelling', 'noir', 'atmospheric', 'Wong Kar-wai', 'color graded'], |
| | description: 'Moody, storytelling movie stills.', |
| | coverColor: 'bg-gray-100', |
| | previewImage: 'https://images.unsplash.com/photo-1470163395405-d2b80e7450ed?auto=format&fit=crop&w=400&q=60', |
| | icon: <Film className="w-5 h-5 text-gray-700" aria-hidden="true" />, |
| | tags: ['recommend'] |
| | }, |
| | { |
| | id: 'british', |
| | name: '英伦 (British)', |
| | prompt: 'British royal style wedding, vintage manor background, groom in morning suit, bride in lace vintage gown, elegant hat, overcast soft light, noble and aristocratic atmosphere.', |
| | promptKeywords: ['royal', 'lace', 'aristocratic', 'manor', 'vintage', 'noble', 'high society'], |
| | description: 'Aristocratic, vintage manor vibes.', |
| | coverColor: 'bg-blue-50', |
| | previewImage: 'https://images.unsplash.com/photo-1505944270255-72b8c68c6a70?auto=format&fit=crop&w=400&q=60', |
| | icon: <Crown className="w-5 h-5 text-blue-600" aria-hidden="true" /> |
| | }, |
| | { |
| | id: 'japanese', |
| | name: '日系 (Japanese)', |
| | prompt: 'Japanese aesthetic, bright and airy, overexposed soft light, film grain, emotional close-up, clean streets or cherry blossoms background, natural makeup, pure and fresh.', |
| | promptKeywords: ['airy', 'soft focus', 'pure', 'fresh', 'minimalist', 'nature', 'bright'], |
| | description: 'Bright, airy, and emotional.', |
| | coverColor: 'bg-sky-50', |
| | previewImage: 'https://images.unsplash.com/photo-1492596985094-52771095ad79?auto=format&fit=crop&w=400&q=60', |
| | icon: <Sun className="w-5 h-5 text-sky-400" aria-hidden="true" /> |
| | }, |
| | { |
| | id: 'fairytale', |
| | name: '童话 (Fairy Tale)', |
| | prompt: 'Disney fairy tale style, magical castle, glowing lights, cinderella dress, sparkles, pumpkin carriage, dreamy blue and purple tones.', |
| | promptKeywords: ['magical', 'fantasy', 'glow', 'dreamy', 'royal', 'Disney-like', 'sparkles'], |
| | description: 'Magical castles and dreams.', |
| | coverColor: 'bg-purple-100', |
| | previewImage: 'https://images.unsplash.com/photo-1520024146169-3240400354ae?auto=format&fit=crop&w=400&q=60', |
| | icon: <Sparkles className="w-5 h-5 text-purple-600" aria-hidden="true" /> |
| | }, |
| | { |
| | id: 'retro', |
| | name: '复古 (Retro)', |
| | prompt: '1980s or 1990s retro hong kong style, warm yellowish tint, vintage disco vibe, sequins, puffy sleeves, nostalgic romance.', |
| | promptKeywords: ['vintage', 'nostalgic', '80s', '90s', 'sequins', 'HK style', 'warm tint'], |
| | description: '80s/90s nostalgic vibes.', |
| | coverColor: 'bg-orange-100', |
| | previewImage: 'https://images.unsplash.com/photo-1551024601-56296352630a?auto=format&fit=crop&w=400&q=60', |
| | icon: <Clock className="w-5 h-5 text-orange-700" aria-hidden="true" />, |
| | tags: ['hot'] |
| | }, |
| | { |
| | id: 'minimalist', |
| | name: '简约 (Minimalist)', |
| | prompt: 'Minimalist wedding, clean lines, plenty of negative space, monochromatic color palette, simple but high-quality silk dress, sophisticated simplicity.', |
| | promptKeywords: ['clean', 'silk', 'sophisticated', 'monochrome', 'negative space', 'modern', 'understated'], |
| | description: 'Less is more, sophisticated.', |
| | coverColor: 'bg-stone-50', |
| | previewImage: 'https://images.unsplash.com/photo-1445633475854-60c07d57cf84?auto=format&fit=crop&w=400&q=60', |
| | icon: <Aperture className="w-5 h-5 text-stone-500" aria-hidden="true" /> |
| | }, |
| | { |
| | id: 'cyberpunk', |
| | name: '赛博朋克 (Cyberpunk)', |
| | prompt: 'Cyberpunk wedding style, neon lights, night city, rain, futuristic techwear mixed with wedding attire, glowing accessories, blue and pink lighting, Blade Runner aesthetic.', |
| | promptKeywords: ['neon', 'futuristic', 'high-tech', 'night city', 'glow', 'synthetic', 'urban'], |
| | description: 'Neon, high-tech, futuristic city.', |
| | coverColor: 'bg-cyan-100', |
| | previewImage: 'https://images.unsplash.com/photo-1535295972055-1c762f4483e5?auto=format&fit=crop&w=400&q=60', |
| | icon: <Cpu className="w-5 h-5 text-cyan-600" aria-hidden="true" />, |
| | tags: ['new'], |
| | isLocked: true |
| | } |
| | ]; |
| |
|
| | export const StyleSelector: React.FC<StyleSelectorProps> = ({ |
| | selectedStyle, |
| | selectedStyles = [], |
| | onSelect, |
| | onCustomSelect, |
| | onLuckySelect, |
| | onSlashClick, |
| | disabled, |
| | language, |
| | recommendedStyleIds, |
| | isVipUnlocked, |
| | resolution, |
| | onResolutionChange, |
| | onBlendSelect |
| | }) => { |
| | const t = TRANSLATIONS[language]; |
| | const customFileInputRef = useRef<HTMLInputElement>(null); |
| | const [searchTerm, setSearchTerm] = useState(''); |
| | const { currentUser, guestFavorites, toggleFavorite } = useUserStore(); |
| | const favorites = currentUser ? (currentUser.favorites || []) : guestFavorites; |
| | |
| | const filteredStyles = useMemo(() => { |
| | return STYLES.filter(style => { |
| | const name = (t.styles as any)[style.id] || style.name; |
| | const term = searchTerm.toLowerCase(); |
| | return name.toLowerCase().includes(term) || style.description.toLowerCase().includes(term); |
| | }); |
| | }, [searchTerm, language, t.styles]); |
| |
|
| | const handleCustomStyleUpload = (e: React.ChangeEvent<HTMLInputElement>) => { |
| | const file = e.target.files?.[0]; |
| | if (file) { |
| | const reader = new FileReader(); |
| | reader.onloadend = () => { |
| | onCustomSelect(reader.result as string); |
| | }; |
| | reader.readAsDataURL(file); |
| | } |
| | }; |
| |
|
| | const handleLuckyDip = () => { |
| | if (disabled) return; |
| | const randomIndex = Math.floor(Math.random() * STYLES.length); |
| | const randomStyle = STYLES[randomIndex]; |
| | onSelect(randomStyle); |
| | onLuckySelect(); |
| | }; |
| |
|
| | const handleStyleInteraction = (style: WeddingStyle) => { |
| | if (disabled) return; |
| | const isLocked = style.isLocked && !isVipUnlocked; |
| | if (isLocked && currentUser?.role !== 'admin') { |
| | onSlashClick(style); |
| | return; |
| | } |
| |
|
| | if (onBlendSelect) { |
| | const existsIdx = selectedStyles.findIndex(s => s.id === style.id); |
| | if (existsIdx !== -1) { |
| | |
| | onBlendSelect(selectedStyles.filter(s => s.id !== style.id)); |
| | } else { |
| | |
| | if (selectedStyles.length < 2) { |
| | onBlendSelect([...selectedStyles, style]); |
| | } else { |
| | |
| | onBlendSelect([selectedStyles[0], style]); |
| | } |
| | } |
| | } else { |
| | onSelect(style); |
| | } |
| | }; |
| |
|
| | const isBlendReady = selectedStyles.length === 2; |
| |
|
| | return ( |
| | <div className="space-y-6 relative"> |
| | {/* Toolbar */} |
| | <div className="flex flex-col sm:flex-row gap-3"> |
| | <div className="relative flex-1"> |
| | <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> |
| | <input |
| | type="text" |
| | placeholder={t.styleSearchPlace || "Search styles..."} |
| | value={searchTerm} |
| | onChange={e => setSearchTerm(e.target.value)} |
| | className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-gray-200 bg-gray-50 outline-none text-sm transition-all focus:bg-white focus:ring-2 focus:ring-rose-100" |
| | disabled={disabled} |
| | /> |
| | </div> |
| | |
| | <div className="flex bg-gray-100 p-1 rounded-xl shrink-0"> |
| | {['standard', 'high', 'ultra'].map((res) => ( |
| | <button |
| | key={res} |
| | onClick={() => onResolutionChange(res as Resolution)} |
| | className={`px-3 py-1.5 rounded-lg text-[10px] font-black transition-all ${resolution === res ? 'bg-white shadow text-rose-600' : 'text-gray-500'}`} |
| | > |
| | {res === 'standard' ? 'HD' : res === 'high' ? '4K' : '8K'} |
| | </button> |
| | ))} |
| | </div> |
| | </div> |
| | |
| | {/* Quick Actions & Blender Status */} |
| | <ul className="grid grid-cols-2 sm:grid-cols-4 gap-3"> |
| | <li> |
| | <button |
| | onClick={() => customFileInputRef.current?.click()} |
| | disabled={disabled} |
| | className={`w-full h-full group flex flex-col items-center justify-center p-3 rounded-xl border-2 border-dashed transition-all ${selectedStyle?.id === 'custom' ? 'border-rose-500 bg-rose-50 text-rose-600' : 'border-gray-200 hover:border-rose-300 hover:bg-rose-50'}`} |
| | > |
| | <div className="relative"> |
| | <Upload className={`w-4 h-4 mb-1 ${selectedStyle?.id === 'custom' ? 'text-rose-600' : 'text-rose-500'}`} /> |
| | {selectedStyle?.id === 'custom' && ( |
| | <div className="absolute -top-1 -right-1 w-2 h-2 bg-rose-600 rounded-full border border-white"></div> |
| | )} |
| | </div> |
| | <span className="text-[10px] font-bold text-gray-700">{t.customStyle}</span> |
| | <input |
| | type="file" |
| | accept="image/*" |
| | className="hidden" |
| | ref={customFileInputRef} |
| | onChange={handleCustomStyleUpload} |
| | /> |
| | </button> |
| | </li> |
| | <li> |
| | <button |
| | onClick={handleLuckyDip} |
| | disabled={disabled} |
| | className="w-full h-full group flex flex-col items-center justify-center p-3 rounded-xl border border-gray-100 hover:border-amber-200 bg-white transition-all shadow-sm active:scale-95" |
| | > |
| | <Dice5 className="w-4 h-4 text-amber-500 mb-1 animate-in fade-in zoom-in" /> |
| | <span className="text-[10px] font-bold text-gray-700">{t.luckyStyle}</span> |
| | </button> |
| | </li> |
| | <li className="col-span-2"> |
| | <div className={`w-full h-full rounded-xl border p-3 flex items-center justify-between transition-all ${isBlendReady ? 'bg-rose-600 border-rose-500 shadow-lg shadow-rose-200' : 'bg-rose-50/50 border-rose-100'}`}> |
| | <div className="flex flex-col"> |
| | <div className="flex items-center gap-1.5"> |
| | <GitMerge className={`w-4 h-4 ${isBlendReady ? 'text-white' : 'text-rose-600'}`} /> |
| | <span className={`text-[11px] font-black uppercase tracking-wider ${isBlendReady ? 'text-white' : 'text-rose-900'}`}>Style Blender</span> |
| | </div> |
| | <span className={`text-[9px] font-bold ${isBlendReady ? 'text-rose-100' : 'text-rose-400'}`}> |
| | {isBlendReady ? "Ready to synthesize" : "Pick 2 to blend aesthetics"} |
| | </span> |
| | </div> |
| | <div className="flex gap-1.5"> |
| | {[0, 1].map(i => ( |
| | <div key={i} className={`w-9 h-9 rounded-lg border-2 flex items-center justify-center text-xs font-black transition-all duration-500 ${selectedStyles[i] ? (isBlendReady ? 'border-white bg-white text-rose-600' : 'border-rose-500 bg-rose-500 text-white shadow-lg') : 'border-rose-200 border-dashed bg-white text-rose-200'}`}> |
| | {selectedStyles[i] ? (i === 0 ? 'A' : 'B') : '+'} |
| | </div> |
| | ))} |
| | </div> |
| | </div> |
| | </li> |
| | </ul> |
| | |
| | {/* Style Grid */} |
| | <section className="space-y-4"> |
| | <ul className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 max-h-[550px] overflow-y-auto pr-2 custom-scrollbar pb-8"> |
| | {filteredStyles.map(style => { |
| | const blendIdx = selectedStyles.findIndex(s => s.id === style.id); |
| | const isSelectedInBlend = blendIdx !== -1; |
| | const isDirectSelected = selectedStyle?.id === style.id; |
| | const isSelected = isDirectSelected || isSelectedInBlend; |
| | |
| | const isLocked = style.isLocked && !isVipUnlocked; |
| | const name = (t.styles as any)[style.id] || style.name; |
| | const isFav = favorites.includes(style.id); |
| | |
| | return ( |
| | <li key={style.id}> |
| | <button |
| | onClick={() => handleStyleInteraction(style)} |
| | disabled={disabled} |
| | className={` |
| | w-full group relative aspect-[4/5] rounded-2xl overflow-hidden text-left transition-all duration-500 |
| | ${isSelected ? 'ring-4 ring-rose-500 ring-offset-2 scale-[1.03] z-10 shadow-2xl' : 'hover:scale-[1.02] shadow-sm'} |
| | ${isLocked ? 'cursor-not-allowed grayscale-[0.5]' : 'cursor-pointer'} |
| | `} |
| | > |
| | <LazyStyleBackground src={style.previewImage} colorClass={style.coverColor} /> |
| | |
| | {/* Status Overlays */} |
| | <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent p-4 flex flex-col justify-end"> |
| | <div className="absolute top-3 left-3 flex flex-col gap-1.5"> |
| | {style.tags?.includes('hot') && ( |
| | <span className="px-2 py-1 bg-red-600 text-white text-[8px] font-black rounded-full shadow-lg flex items-center gap-1 w-fit uppercase"> |
| | <Sparkles className="w-2.5 h-2.5" /> Hot |
| | </span> |
| | )} |
| | {recommendedStyleIds?.includes(style.id) && ( |
| | <span className="px-2 py-1 bg-emerald-500 text-white text-[8px] font-black rounded-full shadow-lg flex items-center gap-1 w-fit uppercase animate-pulse"> |
| | <Check className="w-2.5 h-2.5" /> Best Match |
| | </span> |
| | )} |
| | </div> |
| | |
| | <div className="absolute top-3 right-3 flex gap-2"> |
| | <button |
| | onClick={(e) => { e.stopPropagation(); toggleFavorite(style.id); }} |
| | className={`p-2 rounded-full backdrop-blur-md transition-all ${isFav ? 'bg-yellow-400 text-white scale-110 shadow-lg' : 'bg-black/30 text-white hover:bg-white hover:text-rose-500'}`} |
| | > |
| | <Star className={`w-3.5 h-3.5 ${isFav ? 'fill-current' : ''}`} /> |
| | </button> |
| | {isLocked && <div className="p-2 bg-black/60 backdrop-blur-md rounded-full text-white"><Lock className="w-3.5 h-3.5" /></div>} |
| | |
| | {/* Selection Marker */} |
| | {isSelected && ( |
| | <div className="p-2 bg-rose-600 text-white rounded-full shadow-lg animate-fade-in-down font-black text-[10px] w-8 h-8 flex items-center justify-center"> |
| | {isSelectedInBlend ? (blendIdx === 0 ? 'A' : 'B') : <Check className="w-4 h-4" />} |
| | </div> |
| | )} |
| | </div> |
| | |
| | <div className="space-y-0.5"> |
| | <h3 className="font-black text-white text-sm tracking-tight drop-shadow-lg">{name}</h3> |
| | <p className="text-[10px] text-gray-300 font-medium line-clamp-1">{style.description}</p> |
| | </div> |
| | </div> |
| | </button> |
| | </li> |
| | ); |
| | })} |
| | </ul> |
| | </section> |
| | </div> |
| | ); |
| | }; |
| |
|