168 lines
8.0 KiB
TypeScript
168 lines
8.0 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
|
import { TRANSLATIONS } from '../constants';
|
|
import { AppLanguage } from '../types';
|
|
import { generateImage, generateVideo } from '../services/geminiService';
|
|
import { Loader2, Image as ImageIcon, Video, Download, Sparkles, Key } from 'lucide-react';
|
|
|
|
interface ToolsProps {
|
|
language: AppLanguage;
|
|
hasCustomKey?: boolean;
|
|
}
|
|
|
|
const Tools: React.FC<ToolsProps> = ({ language, hasCustomKey }) => {
|
|
const t = TRANSLATIONS[language];
|
|
const [activeTab, setActiveTab] = useState<'image' | 'video'>('image');
|
|
const [prompt, setPrompt] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [resultUrl, setResultUrl] = useState<string | null>(null);
|
|
const [imageSize, setImageSize] = useState<"1K" | "2K" | "4K">("1K");
|
|
const [videoRatio, setVideoRatio] = useState<"16:9" | "9:16">("16:9");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleGenerate = async () => {
|
|
if (!prompt.trim()) return;
|
|
|
|
// Users must select their own API key for image/video generation tasks
|
|
// Skip this check if user has manually configured a key in settings
|
|
if (!hasCustomKey && typeof (window as any).aistudio !== 'undefined') {
|
|
const hasKey = await (window as any).aistudio.hasSelectedApiKey();
|
|
if (!hasKey) {
|
|
await (window as any).aistudio.openSelectKey();
|
|
// Proceeding assuming selection was successful per guidelines
|
|
}
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
setResultUrl(null);
|
|
try {
|
|
if (activeTab === 'image') {
|
|
const images = await generateImage(prompt, imageSize);
|
|
if (images.length > 0) setResultUrl(images[0]);
|
|
} else {
|
|
const video = await generateVideo(prompt, videoRatio);
|
|
setResultUrl(video);
|
|
}
|
|
} catch (e: any) {
|
|
if (!hasCustomKey && e.message && e.message.includes("Requested entity was not found.")) {
|
|
// Handle race condition or invalid key by prompting re-selection, ONLY if no custom key
|
|
if (typeof (window as any).aistudio !== 'undefined') {
|
|
await (window as any).aistudio.openSelectKey();
|
|
}
|
|
}
|
|
setError(e.message || t.genError);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto p-4 md:p-8 space-y-6">
|
|
<div className="flex space-x-1 bg-slate-200 dark:bg-slate-800 p-1 rounded-xl w-fit animate-fade-in shadow-inner">
|
|
<button
|
|
onClick={() => { setActiveTab('image'); setResultUrl(null); }}
|
|
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'image' ? 'bg-white dark:bg-slate-700 shadow text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200'}`}
|
|
>
|
|
<ImageIcon size={18} />
|
|
<span className="font-medium">{t.imageGen}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => { setActiveTab('video'); setResultUrl(null); }}
|
|
className={`flex items-center space-x-2 px-6 py-2 rounded-lg transition-all active:scale-95 ${activeTab === 'video' ? 'bg-white dark:bg-slate-700 shadow text-purple-600 dark:text-purple-400' : 'text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200'}`}
|
|
>
|
|
<Video size={18} />
|
|
<span className="font-medium">{t.videoGen}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-white dark:bg-slate-900 rounded-3xl shadow-xl border border-slate-100 dark:border-slate-800 p-6 md:p-8 animate-slide-up space-y-6">
|
|
<div className="relative">
|
|
<textarea
|
|
className="w-full p-5 bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-2xl focus:ring-2 focus:ring-blue-500 focus:outline-none focus:bg-white dark:focus:bg-slate-900 transition-all resize-none min-h-[120px] text-slate-700 dark:text-slate-200"
|
|
rows={4}
|
|
placeholder={activeTab === 'image' ? t.imagePromptPlaceholder : t.videoPromptPlaceholder}
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
/>
|
|
<Sparkles className="absolute right-4 bottom-4 text-slate-300 dark:text-slate-600 pointer-events-none" size={20} />
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-6 items-center justify-between">
|
|
<div className="flex gap-6">
|
|
{activeTab === 'image' ? (
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-tight ml-1">{t.imageSize}</span>
|
|
<select
|
|
value={imageSize}
|
|
onChange={(e) => setImageSize(e.target.value as any)}
|
|
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-transparent rounded-xl text-sm font-medium focus:bg-white dark:focus:bg-slate-700 focus:border-blue-200 transition-all"
|
|
>
|
|
<option value="1K">1K (HD)</option>
|
|
<option value="2K">2K (QHD)</option>
|
|
<option value="4K">4K (UHD)</option>
|
|
</select>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-tight ml-1">{t.aspectRatio}</span>
|
|
<select
|
|
value={videoRatio}
|
|
onChange={(e) => setVideoRatio(e.target.value as any)}
|
|
className="px-4 py-2 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 border border-transparent rounded-xl text-sm font-medium focus:bg-white dark:focus:bg-slate-700 focus:border-blue-200 transition-all"
|
|
>
|
|
<option value="16:9">{t.landscape}</option>
|
|
<option value="9:16">{t.portrait}</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleGenerate}
|
|
disabled={loading || !prompt.trim()}
|
|
className="px-8 py-3 bg-blue-600 text-white font-bold rounded-2xl hover:bg-blue-700 active:scale-95 disabled:opacity-50 flex items-center space-x-2 transition-all shadow-lg shadow-blue-200 dark:shadow-none"
|
|
>
|
|
{loading ? <Loader2 className="animate-spin" size={20} /> : null}
|
|
<span>{loading ? t.generating : t.generate}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="p-4 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded-xl border border-red-100 dark:border-red-900/30 animate-slide-up">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{loading && activeTab === 'video' && (
|
|
<div className="text-center py-4 text-sm text-slate-500 dark:text-slate-400 animate-breathe">
|
|
{t.videoDuration}
|
|
</div>
|
|
)}
|
|
|
|
{resultUrl && (
|
|
<div className="mt-8 border-t border-slate-100 dark:border-slate-800 pt-8 animate-slide-up">
|
|
<div className="relative rounded-2xl overflow-hidden bg-slate-900 flex justify-center items-center shadow-2xl group">
|
|
{activeTab === 'image' ? (
|
|
<img src={resultUrl} alt="Generated" className="max-h-[600px] w-auto object-contain transition-transform duration-500 group-hover:scale-[1.02]" />
|
|
) : (
|
|
<video src={resultUrl} controls autoPlay loop className="max-h-[600px] w-auto" />
|
|
)}
|
|
<a
|
|
href={resultUrl}
|
|
download={`sociopal-${activeTab}-${Date.now()}.${activeTab === 'image' ? 'png' : 'mp4'}`}
|
|
className="absolute top-4 right-4 bg-white/90 backdrop-blur p-3 rounded-full shadow-lg hover:bg-white text-slate-800 transition-all opacity-0 group-hover:opacity-100 translate-y-2 group-hover:translate-y-0 active:scale-90"
|
|
title={t.download}
|
|
>
|
|
<Download size={24} />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Tools;
|