ai / components /StyleSelector.tsx
Lianjx's picture
Upload 75 files
8fb4cca verified
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) {
// Toggle off if already selected in blend
onBlendSelect(selectedStyles.filter(s => s.id !== style.id));
} else {
// Selection logic for blend: limit to 2
if (selectedStyles.length < 2) {
onBlendSelect([...selectedStyles, style]);
} else {
// Replace second if already have two
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>
);
};