Spaces:
Running
Running
// src/App.tsx (نسخه نهایی با آیکون سر انسان) | |
import React, { useEffect, useRef, useState, FC } from "react"; | |
import './App.scss'; | |
import { AppProvider, useAppContext, PersonalityType, PersonalityInstructions } from "./contexts/AppContext"; | |
import ControlTray from "./components/control-tray/ControlTray"; | |
import { IOSModal } from "./components/ios-modal/IOSModal"; | |
import { isIOS } from "./lib/platform"; | |
import cn from "classnames"; | |
import Logo from "./components/logo/Logo"; | |
import { LiveConfig } from "./multimodal-live-types"; | |
// کامپوننت مودال (که قبلاً در فایل جدا بود، برای سادگی اینجا قرار گرفته) | |
interface CustomModalProps { | |
isOpen: boolean; | |
onClose: () => void; | |
onSave: (name: string, instructions: string) => void; | |
initialName: string; | |
initialInstructions: string; | |
} | |
const CustomModal: FC<CustomModalProps> = ({ isOpen, onClose, onSave, initialName, initialInstructions }) => { | |
const [name, setName] = useState(initialName); | |
const [instructions, setInstructions] = useState(initialInstructions); | |
useEffect(() => { | |
if (isOpen) { | |
setName(initialName); | |
setInstructions(initialInstructions); | |
} | |
}, [isOpen, initialName, initialInstructions]); | |
if (!isOpen) return null; | |
const handleSave = () => { | |
if (name.trim() && instructions.trim()) { | |
onSave(name.trim(), instructions.trim()); | |
onClose(); | |
} else { alert("لطفا نام و دستورالعملها را وارد کنید."); } | |
}; | |
return ( | |
<div className="modal-overlay" onClick={onClose}> | |
<div className="modal-content" onClick={(e) => e.stopPropagation()}> | |
<div className="modal-header"><h3>ساخت شخصیت اختصاصی</h3><button className="close-button" onClick={onClose}>×</button></div> | |
<div className="modal-body"> | |
<div className="form-group"><label htmlFor="name">نام شما (برای صدا زدن)</label><input id="name" type="text" placeholder="مثلا: علی" value={name} onChange={(e) => setName(e.target.value)} /></div> | |
<div className="form-group"><label htmlFor="instructions">دستورالعملهای شخصیت</label><textarea id="instructions" placeholder="مثلا: خیلی خودمونی و بامزه صحبت کن..." value={instructions} onChange={(e) => setInstructions(e.target.value)} rows={6} /><small>اینجا فقط سبک صحبت کردن دستیار را مشخص کنید.</small></div> | |
</div> | |
<div className="modal-footer"><button className="save-button" onClick={handleSave}>ذخیره و فعالسازی</button></div> | |
</div> | |
</div> | |
); | |
}; | |
// کامپوننت منوی شخصیتها | |
const PersonalityMenu: React.FC<{ isOpen: boolean; onClose: () => void; onSelect: (p: PersonalityType) => void; }> = ({ isOpen, onClose, onSelect }) => { | |
const menuRef = useRef<HTMLDivElement>(null); | |
const { selectedPersonality } = useAppContext(); | |
const personalityIcons: Record<PersonalityType, string> = { default: "person", teacher: "school", poetic: "auto_awesome", funny: "sentiment_satisfied", custom: "tune" }; | |
const personalityLabels: Record<PersonalityType, string> = { default: 'دستیار پیشفرض', teacher: 'استاد زبان', poetic: 'حس خوب', funny: 'شوخطبع', custom: 'شخصیت اختصاصی' }; | |
useEffect(() => { | |
const handleClickOutside = (e: MouseEvent) => menuRef.current && !menuRef.current.contains(e.target as Node) && onClose(); | |
if (isOpen) document.addEventListener('mousedown', handleClickOutside); | |
return () => document.removeEventListener('mousedown', handleClickOutside); | |
}, [isOpen, onClose]); | |
if (!isOpen) return null; | |
return ( | |
<div ref={menuRef} className="personality-popover-wrapper open"><div className="popover-content"><ul>{(Object.keys(personalityIcons) as PersonalityType[]).map(key => (<li key={key} className={cn({ active: selectedPersonality === key })} onClick={() => onSelect(key)}><div><span className="material-symbols-outlined">{personalityIcons[key]}</span>{personalityLabels[key]}</div>{selectedPersonality === key && <span className="material-symbols-outlined tick">done</span>}</li>))}</ul></div></div> | |
); | |
}; | |
// کامپوننت داخلی اصلی برنامه | |
const AppInternal: React.FC = () => { | |
const videoRef = useRef<HTMLVideoElement>(null); | |
const { volume, changePersonality, customUserName, customInstructions } = useAppContext(); | |
const [isUserSpeaking, setIsUserSpeaking] = useState(false); | |
const [isMicActive, setIsMicActive] = useState(false); | |
const [isCamActive, setIsCamActive] = useState(false); | |
const [currentFacingMode, setCurrentFacingMode] = useState<'user' | 'environment'>('user'); | |
const [isNotificationOpen, setIsNotificationOpen] = useState(false); | |
const [isPersonalityMenuOpen, setIsPersonalityMenuOpen] = useState(false); | |
const [isCustomModalOpen, setIsCustomModalOpen] = useState(false); | |
const notificationButtonRef = useRef<HTMLButtonElement>(null); | |
const notificationPopoverRef = useRef<HTMLDivElement>(null); | |
const handleSelectPersonality = (p: PersonalityType) => { | |
setIsPersonalityMenuOpen(false); | |
if (p === 'custom') setIsCustomModalOpen(true); | |
else changePersonality(p); | |
}; | |
useEffect(() => { | |
const handleClickOutside = (event: MouseEvent) => { | |
if (isNotificationOpen && notificationPopoverRef.current && !notificationPopoverRef.current.contains(event.target as Node) && notificationButtonRef.current && !notificationButtonRef.current.contains(event.target as Node)) { | |
setIsNotificationOpen(false); | |
} | |
}; | |
document.addEventListener("mousedown", handleClickOutside); | |
return () => document.removeEventListener("mousedown", handleClickOutside); | |
}, [isNotificationOpen]); | |
return ( | |
<> | |
<div className="main-wrapper"> | |
<div className="header-controls"> | |
<button aria-label="انتخاب شخصیت" className="header-icon-button" onClick={() => setIsPersonalityMenuOpen(v => !v)}> | |
{/* ✅ تغییر اصلی اینجاست */} | |
<span className="material-symbols-outlined">account_circle</span> | |
</button> | |
<button ref={notificationButtonRef} aria-label="اطلاعات" className="header-icon-button" onClick={() => setIsNotificationOpen(v => !v)}> | |
<span className="material-symbols-outlined">info</span> | |
</button> | |
</div> | |
<div ref={notificationPopoverRef} id="notification-popover-wrapper" className="notification-popover-wrapper"> | |
<div id="notification-popover" className={cn("popover-content", { "open animate-popover-open-top-center": isNotificationOpen, "animate-popover-close-top-center": !isNotificationOpen && document.getElementById('notification-popover')?.classList.contains('open'), })}> | |
<div className="notification-popover-text-content">مدلهای هوش مصنوعی میتوانند اشتباه کنند، صحت اطلاعات مهم را بررسی کنید و از بیان اطلاعات حساس بپرهیزید.</div> | |
</div> | |
</div> | |
<PersonalityMenu isOpen={isPersonalityMenuOpen} onClose={() => setIsPersonalityMenuOpen(false)} onSelect={handleSelectPersonality} /> | |
<div className="media-area"> | |
<video id="video-feed" ref={videoRef} autoPlay playsInline className={cn({ hidden: !isCamActive }, { "scale-x-[-1]": currentFacingMode === 'user' })} /> | |
{isMicActive && !isCamActive && <div id="large-logo-container"><Logo isMini={false} isActive={true} isAi={false} speakingVolume={volume} isUserSpeaking={isUserSpeaking} /></div>} | |
</div> | |
<ControlTray videoRef={videoRef} onUserSpeakingChange={setIsUserSpeaking} isAppMicActive={isMicActive} onAppMicToggle={setIsMicActive} isAppCamActive={isCamActive} onAppCamToggle={setIsCamActive} currentFacingMode={currentFacingMode} onFacingModeChange={setCurrentFacingMode} /> | |
</div> | |
<CustomModal isOpen={isCustomModalOpen} onClose={() => setIsCustomModalOpen(false)} onSave={(name, instructions) => changePersonality('custom', { name, instructions })} initialName={customUserName} initialInstructions={customInstructions} /> | |
</> | |
); | |
}; | |
// کامپوننت ریشه App | |
function App() { | |
const [showIOSModal, setShowIOSModal] = useState(false); | |
const [personalityInstructions, setPersonalityInstructions] = useState<PersonalityInstructions | null>(null); | |
const [loadingError, setLoadingError] = useState<string | null>(null); | |
useEffect(() => { | |
if (isIOS()) setShowIOSModal(true); | |
const fetchInstructions = async () => { | |
try { | |
const res = await fetch('/api/instructions'); | |
if (!res.ok) throw new Error(`Network error: ${res.status}`); | |
setPersonalityInstructions(await res.json()); | |
} catch (e) { setLoadingError("امکان دریافت تنظیمات شخصیتها وجود ندارد. لطفاً صفحه را رفرش کنید."); } | |
}; | |
fetchInstructions(); | |
}, []); | |
if (loadingError) return <div className="loading-screen">{loadingError}</div>; | |
if (!personalityInstructions) return <div className="loading-screen">در حال بارگذاری...</div>; | |
const initialAppConfig: LiveConfig = { model: "models/gemini-2.0-flash-exp" }; | |
return ( | |
<AppProvider initialConfig={initialAppConfig} personalityInstructions={personalityInstructions}><AppInternal /><IOSModal isOpen={showIOSModal} onClose={() => setShowIOSModal(false)} /></AppProvider> | |
); | |
} | |
export default App; |