Spaces:
Running
Running
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { MODELS } from '../constants'; | |
| import { Model } from '../types'; | |
| interface ModelGridProps { | |
| customModels: Model[]; | |
| onSelectModel: (model: Model) => void; | |
| onOpenCustom: () => void; | |
| onAddNewModel: () => void; | |
| onEditModel: (model: Model) => void; | |
| } | |
| const ModelGrid: React.FC<ModelGridProps> = ({ | |
| customModels, | |
| onSelectModel, | |
| onOpenCustom, | |
| onAddNewModel, | |
| onEditModel | |
| }) => { | |
| const [activeTab, setActiveTab] = useState('all'); | |
| const [playingModelId, setPlayingModelId] = useState<string | null>(null); | |
| const audioRef = useRef<HTMLAudioElement | null>(null); | |
| const categories: { key: string; title: string; icon: string }[] = [ | |
| { key: 'singers', title: 'خوانندگان', icon: 'fa-music' }, | |
| { key: 'dubbers', title: 'دوبلورها', icon: 'fa-microphone-alt' }, | |
| { key: 'cartoons', title: 'کارتون', icon: 'fa-child' }, | |
| { key: 'famous', title: 'مشاهیر', icon: 'fa-star' }, | |
| { key: 'alpha', title: 'گویندگان آلفا', icon: 'fa-user-tie' }, | |
| ]; | |
| useEffect(() => { | |
| return () => { | |
| if (audioRef.current) { | |
| audioRef.current.pause(); | |
| audioRef.current = null; | |
| } | |
| }; | |
| }, []); | |
| const togglePreview = (e: React.MouseEvent, model: Model) => { | |
| e.stopPropagation(); | |
| if (playingModelId === model.id) { | |
| if (audioRef.current) { | |
| audioRef.current.pause(); | |
| audioRef.current = null; | |
| } | |
| setPlayingModelId(null); | |
| return; | |
| } | |
| if (audioRef.current) audioRef.current.pause(); | |
| let audioSrc = model.sampleAudio; | |
| // If no sample, and it's custom, maybe use the ref file if it's a blob? | |
| // Actually standard models have sampleAudio, custom ones might use their ref as sample | |
| if (!audioSrc && model.isCustom && typeof model.refFile !== 'string') { | |
| audioSrc = URL.createObjectURL(model.refFile); | |
| } | |
| if (audioSrc) { | |
| const audio = new Audio(audioSrc); | |
| audioRef.current = audio; | |
| audio.onerror = () => setPlayingModelId(null); | |
| audio.play().catch(() => setPlayingModelId(null)); | |
| setPlayingModelId(model.id); | |
| audio.onended = () => { | |
| setPlayingModelId(null); | |
| audioRef.current = null; | |
| }; | |
| } | |
| }; | |
| const renderModelCard = (model: Model) => { | |
| // Handling image URL for custom models (Blobs) | |
| const imageUrl = (model.isCustom && typeof model.image !== 'string') | |
| ? URL.createObjectURL(model.image as any) | |
| : model.image; | |
| return ( | |
| <div | |
| key={model.id} | |
| onClick={() => onSelectModel(model)} | |
| className="group relative cursor-pointer rounded-2xl overflow-hidden aspect-square shadow-sm hover:shadow-xl transition-all duration-300 active:scale-95 bg-gray-100" | |
| > | |
| <img | |
| src={imageUrl} | |
| alt={model.name} | |
| loading="lazy" | |
| className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" | |
| /> | |
| <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-80"></div> | |
| {/* Play Button */} | |
| <button | |
| onClick={(e) => togglePreview(e, model)} | |
| className={`absolute top-2 left-2 w-8 h-8 rounded-full flex items-center justify-center backdrop-blur-md shadow-sm transition-all duration-300 z-20 | |
| ${playingModelId === model.id | |
| ? 'bg-red-500/90 text-white scale-110 shadow-red-500/50' | |
| : 'bg-white/30 hover:bg-white/50 text-white hover:scale-105' | |
| }`} | |
| > | |
| {playingModelId === model.id ? ( | |
| <div className="flex items-center justify-center h-full w-full relative"> | |
| <i className="fas fa-stop text-xs relative z-10"></i> | |
| <span className="absolute inset-0 rounded-full bg-red-400 animate-ping opacity-75"></span> | |
| </div> | |
| ) : ( | |
| <i className="fas fa-play text-xs ml-0.5"></i> | |
| )} | |
| </button> | |
| {/* Edit Button for Custom Models */} | |
| {model.isCustom && ( | |
| <button | |
| onClick={(e) => { e.stopPropagation(); onEditModel(model); }} | |
| className="absolute top-2 right-2 w-8 h-8 rounded-full bg-amber-500/90 text-white flex items-center justify-center shadow-lg hover:scale-110 transition-all z-20" | |
| > | |
| <i className="fas fa-edit text-xs"></i> | |
| </button> | |
| )} | |
| <div className="absolute bottom-0 left-0 right-0 p-3"> | |
| <span className="block text-white text-sm font-bold truncate"> | |
| {model.name} | |
| </span> | |
| <span className="text-[10px] text-gray-300 flex items-center gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300 transform translate-y-2 group-hover:translate-y-0"> | |
| <i className="fas fa-wave-square"></i> انتخاب مدل | |
| </span> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="pb-24 animate-[fadeIn_0.5s_ease-out]"> | |
| {/* Banner / CTA */} | |
| <div | |
| onClick={onOpenCustom} | |
| className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-gray-900 to-gray-800 p-6 mb-6 shadow-xl shadow-gray-900/20 cursor-pointer group" | |
| > | |
| <div className="absolute top-0 left-0 w-full h-full opacity-10 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')]"></div> | |
| <div className="relative z-10 flex items-center justify-between"> | |
| <div> | |
| <h3 className="text-white font-bold text-lg mb-1">هنوز مدل دلخواهت رو پیدا نکردی؟</h3> | |
| <p className="text-gray-400 text-xs">ساخت صدای اختصاصی با آپلود فایل</p> | |
| </div> | |
| <div className="w-10 h-10 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center group-hover:bg-white/20 transition-colors"> | |
| <i className="fas fa-arrow-left text-white"></i> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Category Tabs */} | |
| <div className="flex overflow-x-auto pb-4 gap-3 mb-2 px-1 no-scrollbar" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}> | |
| <button | |
| onClick={() => setActiveTab('all')} | |
| className={`flex-shrink-0 px-5 py-2.5 rounded-full text-sm font-bold transition-all duration-300 border | |
| ${activeTab === 'all' | |
| ? 'bg-gradient-to-r from-primary to-secondary text-white border-transparent shadow-lg shadow-indigo-200' | |
| : 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'}`} | |
| > | |
| همه | |
| </button> | |
| {categories.map((cat) => ( | |
| <button | |
| key={cat.key} | |
| onClick={() => setActiveTab(cat.key)} | |
| className={`flex-shrink-0 px-4 py-2.5 rounded-full text-sm font-bold transition-all duration-300 border flex items-center gap-2 | |
| ${activeTab === cat.key | |
| ? 'bg-gradient-to-r from-primary to-secondary text-white border-transparent shadow-lg shadow-indigo-200' | |
| : 'bg-white text-gray-600 border-gray-200 hover:bg-gray-50'}`} | |
| > | |
| <i className={`fas ${cat.icon} text-xs`}></i> | |
| {cat.title} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Grid Content */} | |
| <div className="min-h-[300px]"> | |
| {categories.map((category) => { | |
| if (activeTab !== 'all' && activeTab !== category.key) return null; | |
| const categoryModels = MODELS.filter(m => m.category === category.key); | |
| const isAlpha = category.key === 'alpha'; | |
| return ( | |
| <div key={category.key} className="mb-8 animate-[fadeIn_0.5s_ease-out]"> | |
| <div className="flex items-center gap-2 mb-4 px-2"> | |
| <div className={`w-8 h-8 rounded-full flex items-center justify-center text-white shadow-sm | |
| ${activeTab === category.key ? 'bg-secondary' : 'bg-gray-300'}`}> | |
| <i className={`fas ${category.icon} text-xs`}></i> | |
| </div> | |
| <h2 className="text-lg font-black text-gray-800">{category.title}</h2> | |
| {activeTab === 'all' && <div className="h-px bg-gray-100 flex-1 ml-4"></div>} | |
| </div> | |
| <div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> | |
| {categoryModels.map(renderModelCard)} | |
| {/* Special Logic: User Custom Models next to Maryam (Alpha) */} | |
| {isAlpha && customModels.map(renderModelCard)} | |
| {/* PLUS BUTTON */} | |
| {isAlpha && ( | |
| <div | |
| onClick={onAddNewModel} | |
| className="relative group cursor-pointer rounded-2xl aspect-square border-4 border-dashed border-gray-200 flex flex-col items-center justify-center gap-3 transition-all duration-300 hover:border-primary/50 hover:bg-primary/5 active:scale-95" | |
| > | |
| <div className="w-14 h-14 rounded-full bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-white shadow-lg shadow-primary/20 group-hover:scale-110 transition-transform"> | |
| <i className="fas fa-plus text-2xl animate-[pulse_2s_infinite]"></i> | |
| </div> | |
| <span className="text-[10px] font-black text-gray-500 group-hover:text-primary transition-colors text-center px-1">ساخت مدل صدای دلخواه</span> | |
| <div className="absolute top-2 right-2 flex h-2 w-2"> | |
| <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span> | |
| <span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ModelGrid; | |