mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-16 19:59:42 +02:00
Add Google Gemini TTS as alternative to Edge TTS
- Store Gemini API key globally in appConfig (single key for all users)
- Add /api/settings/gemini GET/POST/DELETE routes for API key management
- Backend: add tts_engine parameter ("edge" or "gemini") to FFmpeg service
- Backend: add generate_tts_gemini() using Google Cloud TTS REST API
- Frontend: Settings page shows Google Gemini API key input card
- Frontend: new-reel, mobile/new-reel, remotion-video, mobile/remotion-video
pages now have Edge/Gemini engine toggle and French voice selector
- Fix tts-preview route to extract ttsVoice from req.body instead of
undefined voice variable
This commit is contained in:
1 parent
59bc56aedc
commit
72b13b3e42
12 files changed
+605
-29
No files matched your search
@@ -58,6 +58,29 @@ export default function MobileNewReel() {
|
||||
|
||||
// État TTS
|
||||
const [ttsEnabled, setTtsEnabled] = useState(false);
|
||||
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
|
||||
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
|
||||
|
||||
// French Gemini voices
|
||||
const geminiVoices = [
|
||||
{ label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' },
|
||||
{ label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' },
|
||||
{ label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' },
|
||||
{ label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' },
|
||||
{ label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' },
|
||||
{ label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' },
|
||||
{ label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' },
|
||||
{ label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' },
|
||||
];
|
||||
|
||||
// French Edge TTS voices
|
||||
const edgeVoices = [
|
||||
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
|
||||
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
|
||||
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
|
||||
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
|
||||
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
|
||||
];
|
||||
|
||||
// État audio preview
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
@@ -82,14 +105,15 @@ export default function MobileNewReel() {
|
||||
const timer = setTimeout(() => {
|
||||
apiRequest('POST', '/api/reels/sync-info', {
|
||||
text: overlayText,
|
||||
voice: "fr-FR-VivienneMultilingualNeural",
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => setSyncInfo(data))
|
||||
.catch(() => setSyncInfo(null));
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [overlayText, ttsEnabled]);
|
||||
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]);
|
||||
|
||||
|
||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
||||
@@ -230,6 +254,8 @@ export default function MobileNewReel() {
|
||||
stabilize: stabilize,
|
||||
drawText: true,
|
||||
ttsEnabled,
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
enableEndingEffect,
|
||||
});
|
||||
};
|
||||
@@ -450,7 +476,58 @@ export default function MobileNewReel() {
|
||||
<Mic className="w-4 h-4" />
|
||||
<span>TTS — voix activée</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
|
||||
{/* TTS Engine Selector */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Label className="text-xs font-medium">Moteur:</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ttsEngine === 'edge' ? 'default' : 'outline'}
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => {
|
||||
setTtsEngine('edge');
|
||||
setTtsVoice('fr-FR-VivienneMultilingualNeural');
|
||||
}}
|
||||
>
|
||||
Edge
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ttsEngine === 'gemini' ? 'default' : 'outline'}
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => {
|
||||
setTtsEngine('gemini');
|
||||
setTtsVoice('fr-FR-Standard-A');
|
||||
}}
|
||||
>
|
||||
Gemini
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice Selector */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Label className="text-xs font-medium shrink-0">Voix:</Label>
|
||||
<Select value={ttsVoice} onValueChange={setTtsVoice}>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ttsEngine === 'edge' ? (
|
||||
edgeVoices.map(v => (
|
||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||
))
|
||||
) : (
|
||||
geminiVoices.map(v => (
|
||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -461,6 +538,8 @@ export default function MobileNewReel() {
|
||||
try {
|
||||
const response = await apiRequest('POST', '/api/reels/tts-preview', {
|
||||
text: textToTest,
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success && data.audioBase64) {
|
||||
|
||||
@@ -31,6 +31,29 @@ export default function MobileRemotionVideoPage() {
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
|
||||
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
|
||||
|
||||
// French Gemini voices
|
||||
const geminiVoices = [
|
||||
{ label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' },
|
||||
{ label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' },
|
||||
{ label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' },
|
||||
{ label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' },
|
||||
{ label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' },
|
||||
{ label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' },
|
||||
{ label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' },
|
||||
{ label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' },
|
||||
];
|
||||
|
||||
// French Edge TTS voices
|
||||
const edgeVoices = [
|
||||
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
|
||||
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
|
||||
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
|
||||
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
|
||||
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
|
||||
];
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
@@ -111,7 +134,7 @@ export default function MobileRemotionVideoPage() {
|
||||
const ttsText = stripForTTS(overlayText);
|
||||
if (!ttsText) return;
|
||||
try {
|
||||
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText });
|
||||
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
|
||||
const data = await r.json();
|
||||
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
|
||||
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
|
||||
@@ -312,6 +335,39 @@ export default function MobileRemotionVideoPage() {
|
||||
{ttsEnabled && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
{/* TTS Engine Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium">Moteur:</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs px-2"
|
||||
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
|
||||
Edge
|
||||
</Button>
|
||||
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs px-2"
|
||||
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-A'); }}>
|
||||
Gemini
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium shrink-0">Voix:</Label>
|
||||
<Select value={ttsVoice} onValueChange={setTtsVoice}>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ttsEngine === 'edge' ? (
|
||||
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
|
||||
) : (
|
||||
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
|
||||
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
|
||||
</Button>
|
||||
|
||||
@@ -78,6 +78,31 @@ export default function NewReel() {
|
||||
warnings: string[];
|
||||
} | null>(null);
|
||||
|
||||
// TTS Engine & Voice
|
||||
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
|
||||
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
|
||||
|
||||
// French Gemini voices
|
||||
const geminiVoices = [
|
||||
{ label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' },
|
||||
{ label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' },
|
||||
{ label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' },
|
||||
{ label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' },
|
||||
{ label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' },
|
||||
{ label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' },
|
||||
{ label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' },
|
||||
{ label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' },
|
||||
];
|
||||
|
||||
// French Edge TTS voices
|
||||
const edgeVoices = [
|
||||
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
|
||||
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
|
||||
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
|
||||
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
|
||||
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
|
||||
];
|
||||
|
||||
// Enable TTS by default on mobile
|
||||
useEffect(() => {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
@@ -86,7 +111,7 @@ export default function NewReel() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-calculate TTS sync when text changes (edge_tts fallback)
|
||||
// Auto-calculate TTS sync when text changes
|
||||
useEffect(() => {
|
||||
if (!ttsEnabled || !overlayText.trim()) {
|
||||
setSyncInfo(null);
|
||||
@@ -95,14 +120,14 @@ export default function NewReel() {
|
||||
const timer = setTimeout(() => {
|
||||
apiRequest('POST', '/api/reels/sync-info', {
|
||||
text: overlayText,
|
||||
voice: "fr-FR-VivienneMultilingualNeural",
|
||||
voice: ttsVoice,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => setSyncInfo(data))
|
||||
.catch(() => setSyncInfo(null));
|
||||
}, 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [overlayText, ttsEnabled]);
|
||||
}, [overlayText, ttsEnabled, ttsVoice]);
|
||||
|
||||
|
||||
// État audio preview
|
||||
@@ -360,6 +385,8 @@ export default function NewReel() {
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
musicVolume: musicVolume[0] / 100,
|
||||
ttsEnabled,
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
drawText,
|
||||
stabilize: stabilize,
|
||||
enableEndingEffect,
|
||||
@@ -765,6 +792,57 @@ export default function NewReel() {
|
||||
<span>TTS — voix activée</span>
|
||||
</div>
|
||||
|
||||
{/* TTS Engine Selector */}
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<Label className="text-sm font-medium">Moteur:</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ttsEngine === 'edge' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setTtsEngine('edge');
|
||||
setTtsVoice('fr-FR-VivienneMultilingualNeural');
|
||||
}}
|
||||
>
|
||||
Edge TTS
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={ttsEngine === 'gemini' ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setTtsEngine('gemini');
|
||||
setTtsVoice('fr-FR-Standard-A');
|
||||
}}
|
||||
>
|
||||
Gemini TTS
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice Selector */}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<Label className="text-sm font-medium shrink-0">Voix:</Label>
|
||||
<Select
|
||||
value={ttsVoice}
|
||||
onValueChange={setTtsVoice}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ttsEngine === 'edge' ? (
|
||||
edgeVoices.map(v => (
|
||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||
))
|
||||
) : (
|
||||
geminiVoices.map(v => (
|
||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -776,6 +854,8 @@ export default function NewReel() {
|
||||
try {
|
||||
const response = await apiRequest('POST', '/api/reels/tts-preview', {
|
||||
text: textToTest,
|
||||
ttsEngine,
|
||||
ttsVoice,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success && data.audioBase64) {
|
||||
|
||||
@@ -32,6 +32,29 @@ export default function RemotionVideoPage() {
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
|
||||
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
|
||||
|
||||
// French Gemini voices
|
||||
const geminiVoices = [
|
||||
{ label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' },
|
||||
{ label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' },
|
||||
{ label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' },
|
||||
{ label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' },
|
||||
{ label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' },
|
||||
{ label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' },
|
||||
{ label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' },
|
||||
{ label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' },
|
||||
];
|
||||
|
||||
// French Edge TTS voices
|
||||
const edgeVoices = [
|
||||
{ label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' },
|
||||
{ label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' },
|
||||
{ label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' },
|
||||
{ label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' },
|
||||
{ label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' },
|
||||
];
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
@@ -78,7 +101,7 @@ export default function RemotionVideoPage() {
|
||||
const ttsText = overlayText.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!ttsText) return;
|
||||
try {
|
||||
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText });
|
||||
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
|
||||
const data = await r.json();
|
||||
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
|
||||
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
|
||||
@@ -253,6 +276,39 @@ export default function RemotionVideoPage() {
|
||||
{ttsEnabled && (
|
||||
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
|
||||
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
{/* TTS Engine Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium">Moteur:</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs"
|
||||
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
|
||||
Edge
|
||||
</Button>
|
||||
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs"
|
||||
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-A'); }}>
|
||||
Gemini
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium shrink-0">Voix:</Label>
|
||||
<Select value={ttsVoice} onValueChange={setTtsVoice}>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ttsEngine === 'edge' ? (
|
||||
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
|
||||
) : (
|
||||
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
|
||||
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
|
||||
</Button>
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function Settings() {
|
||||
const [ffmpegApiUrl, setFfmpegApiUrl] = useState("");
|
||||
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
||||
const [externalApiKey, setExternalApiKey] = useState("");
|
||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||
@@ -60,6 +61,11 @@ export default function Settings() {
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
const { data: geminiConfig } = useQuery({
|
||||
queryKey: ['/api/settings/gemini'],
|
||||
enabled: isAdmin,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (cloudinaryConfig) {
|
||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||
@@ -86,9 +92,16 @@ export default function Settings() {
|
||||
}
|
||||
}, [ffmpegConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (geminiConfig) {
|
||||
// Don't pre-fill - user must enter key manually
|
||||
}
|
||||
}, [geminiConfig]);
|
||||
|
||||
// Vérifier si une config OpenRouter existe déjà
|
||||
const hasExistingConfig = !!openrouterConfig;
|
||||
const hasExistingFfmpegConfig = !!ffmpegConfig;
|
||||
const hasExistingGeminiConfig = !!(geminiConfig as any)?.configured;
|
||||
|
||||
const saveCloudinaryMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -157,6 +170,31 @@ export default function Settings() {
|
||||
},
|
||||
});
|
||||
|
||||
const saveGeminiMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload: any = {};
|
||||
if (geminiApiKey && geminiApiKey.trim() !== "") {
|
||||
payload.apiKey = geminiApiKey;
|
||||
}
|
||||
return apiRequest('POST', '/api/settings/gemini', payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/settings/gemini'] });
|
||||
toast({
|
||||
title: "Configuration sauvegardée",
|
||||
description: "Votre clé API Gemini a été enregistrée",
|
||||
});
|
||||
setGeminiApiKey("");
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de sauvegarder la configuration Gemini",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
@@ -521,6 +559,52 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Google Gemini TTS */}
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mic className="w-5 h-5" />
|
||||
Google Gemini TTS
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configurez votre clé API Google pour utiliser Gemini TTS dans les Reels
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geminiApiKey">Clé API Google Gemini</Label>
|
||||
<Input
|
||||
id="geminiApiKey"
|
||||
type="password"
|
||||
value={geminiApiKey}
|
||||
onChange={(e) => setGeminiApiKey(e.target.value)}
|
||||
placeholder={hasExistingGeminiConfig ? "••••••••••••••••" : "AIza..."}
|
||||
data-testid="input-gemini-api-key"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{hasExistingGeminiConfig ? (
|
||||
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span>
|
||||
) : (
|
||||
"Obtenez une clé API sur console.cloud.google.com → APIs & Services → Credentials"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => saveGeminiMutation.mutate()}
|
||||
disabled={
|
||||
saveGeminiMutation.isPending ||
|
||||
(!hasExistingGeminiConfig && !geminiApiKey)
|
||||
}
|
||||
data-testid="button-save-gemini"
|
||||
className="w-full"
|
||||
>
|
||||
{saveGeminiMutation.isPending ? "Enregistrement..." : "Enregistrer Gemini TTS"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
|
||||
Reference in new issue
Block a user