mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 22:48:09 +02:00
Le petit logo affiché pendant la vidéo devient optionnel (activé par défaut), sur les 4 pages Reel (vidéo et images, bureau et mobile) ainsi que dans l'aperçu. Le grand logo de l'effet de fin reste piloté par son propre interrupteur. Pris en charge par le rendu Remotion et par le rendu FFmpeg de secours (show_watermark). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
539 lines
27 KiB
TypeScript
539 lines
27 KiB
TypeScript
import { useEffect, useMemo, useState, useRef } from "react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
|
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
|
import { ReelPreview } from "@/components/reels/reel-preview";
|
|
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
|
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Slider } from "@/components/ui/slider";
|
|
import { UploadCloud, Video, Loader2, Menu, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send, Camera, ImagePlus } from "lucide-react";
|
|
import { MediaThumbnail } from "@/components/media-thumbnail";
|
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
|
import Sidebar from "@/components/sidebar";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { DateTimePicker } from "@/components/datetime-picker";
|
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
|
import type { Media, SocialPage } from "@shared/schema";
|
|
import { SiFacebook, SiTiktok } from "react-icons/si";
|
|
|
|
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
|
|
|
|
|
|
function stripForTTS(text: string) {
|
|
return text.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF]/g, '').replace(/[\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
|
|
export default function MobileRemotionVideoPage() {
|
|
const [images, setImages] = useState<File[]>([]);
|
|
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
|
|
const [overlayText, setOverlayText] = useState("");
|
|
const [productInfo, setProductInfo] = useState("");
|
|
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
|
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
|
engine: 'gemini',
|
|
voice: DEFAULT_VOICE.gemini,
|
|
style: DEFAULT_TTS_STYLE,
|
|
});
|
|
const [musicFile, setMusicFile] = useState<File | null>(null);
|
|
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
|
const [musicVolume, setMusicVolume] = useState(0.3);
|
|
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
|
const [isRendering, setIsRendering] = useState(false);
|
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
|
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
|
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
|
const [publishDescription, setPublishDescription] = useState("");
|
|
const [isPublishing, setIsPublishing] = useState(false);
|
|
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
|
const [showLogo, setShowLogo] = useState(true);
|
|
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
|
const [renderProgress, setRenderProgress] = useState(0);
|
|
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
|
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
const cameraInputRef = useRef<HTMLInputElement>(null);
|
|
const galleryInputRef = useRef<HTMLInputElement>(null);
|
|
const { toast } = useToast();
|
|
|
|
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
|
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
|
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
|
|
|
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
|
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
|
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
|
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
|
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
|
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
|
|
|
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
|
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
|
|
|
const renderTargetCheckbox = (page: SocialPage) => (
|
|
<div key={page.id} className="flex items-center gap-3">
|
|
<Checkbox
|
|
id={`page-${page.id}`}
|
|
checked={selectedPageIds.includes(page.id)}
|
|
onCheckedChange={checked =>
|
|
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
|
}
|
|
/>
|
|
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
|
|
{page.pageName}
|
|
</label>
|
|
</div>
|
|
);
|
|
|
|
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
|
|
|
const generateTextMutation = useMutation({
|
|
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
|
|
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
|
|
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
|
|
});
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
const filesArray = Array.from(e.target.files);
|
|
const remaining = 4 - selectedLibraryImages.length - images.length;
|
|
|
|
// Corriger les noms de fichiers pour les fichiers venant de la galerie mobile
|
|
const processedFiles = filesArray.slice(0, remaining).map(file => {
|
|
if (!file.name || file.name === 'blob' || file.name === '' || file.name === 'image') {
|
|
const ext = file.type.split('/')[1] || 'jpg';
|
|
return new File([file], `gallery-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${ext}`, { type: file.type });
|
|
}
|
|
return file;
|
|
});
|
|
|
|
setImages(prev => [...prev, ...processedFiles].slice(0, 4 - selectedLibraryImages.length));
|
|
}
|
|
// Reset input pour permettre de resélectionner les mêmes fichiers
|
|
e.target.value = '';
|
|
};
|
|
|
|
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const remaining = 4 - selectedLibraryImages.length - images.length;
|
|
if (remaining <= 0) {
|
|
toast({ title: "4 images max.", variant: "destructive" });
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
let fileToAdd = file;
|
|
if (!file.name || file.name === 'blob' || file.name === '') {
|
|
const ext = file.type.split('/')[1] || 'jpg';
|
|
fileToAdd = new File([file], `camera-${Date.now()}.${ext}`, { type: file.type });
|
|
}
|
|
setImages(prev => [...prev, fileToAdd].slice(0, 4 - selectedLibraryImages.length));
|
|
e.target.value = '';
|
|
};
|
|
|
|
const toggleLibraryImage = (media: Media) => {
|
|
setSelectedLibraryImages(prev => {
|
|
if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id);
|
|
if (images.length + prev.length >= 4) { toast({ title: "4 images max.", variant: "destructive" }); return prev; }
|
|
return [...prev, media];
|
|
});
|
|
};
|
|
|
|
const totalSelected = images.length + selectedLibraryImages.length;
|
|
|
|
const togglePlayPreview = (track: AudioTrack) => {
|
|
if (!audioRef.current) return;
|
|
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
|
|
else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); }
|
|
};
|
|
|
|
const handleGenerate = async () => {
|
|
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
|
|
setIsRendering(true);
|
|
setRenderProgress(0);
|
|
setVideoUrl(null);
|
|
try {
|
|
const formData = new FormData();
|
|
images.forEach(img => formData.append("images", img));
|
|
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
|
if (overlayText) formData.append("overlayText", overlayText);
|
|
formData.append("ttsEnabled", String(ttsEnabled));
|
|
formData.append("ttsEngine", voiceSettings.engine);
|
|
formData.append("ttsVoice", voiceSettings.voice);
|
|
formData.append("ttsStyle", voiceSettings.style);
|
|
formData.append("captionStyle", captionStyle);
|
|
formData.append("showLogo", String(showLogo));
|
|
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
|
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
|
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
|
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
|
|
if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
|
|
const data = await response.json();
|
|
|
|
if (data.jobId) {
|
|
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
|
|
|
|
const checkStatus = async () => {
|
|
try {
|
|
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
|
|
if (!res.ok) {
|
|
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
|
|
throw new Error("Erreur de suivi du rendu");
|
|
}
|
|
const job = await res.json();
|
|
|
|
if (job.status === 'done') {
|
|
setVideoUrl(job.url);
|
|
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
|
toast({ title: "Vidéo générée !" });
|
|
setIsRendering(false);
|
|
} else if (job.status === 'error') {
|
|
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
|
setIsRendering(false);
|
|
} else {
|
|
setRenderProgress(job.progress ?? 0);
|
|
setTimeout(checkStatus, 3000);
|
|
}
|
|
} catch (e: any) {
|
|
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
|
|
setIsRendering(false);
|
|
}
|
|
};
|
|
setTimeout(checkStatus, 3000);
|
|
} else {
|
|
setVideoUrl(data.url);
|
|
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
|
toast({ title: "Vidéo générée !" });
|
|
setIsRendering(false);
|
|
}
|
|
} catch (err: any) {
|
|
toast({ title: "Erreur", description: err.message, variant: "destructive" });
|
|
setIsRendering(false);
|
|
}
|
|
};
|
|
|
|
const handlePublish = async () => {
|
|
if (!videoUrl) return;
|
|
if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; }
|
|
setIsPublishing(true);
|
|
try {
|
|
const response = await apiRequest('POST', '/api/remotion/publish', {
|
|
videoUrl, pageIds: selectedPageIds,
|
|
scheduledFor: scheduledDate?.toISOString(),
|
|
description: publishDescription || undefined,
|
|
});
|
|
const data = await response.json();
|
|
if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur");
|
|
toast({
|
|
title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !",
|
|
description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.",
|
|
});
|
|
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
|
} catch (err: any) {
|
|
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
|
|
} finally { setIsPublishing(false); }
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background pb-24">
|
|
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
|
|
|
{/* Header */}
|
|
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center gap-3">
|
|
<Sheet>
|
|
<SheetTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="-ml-2"><Menu className="h-6 w-6" /></Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="left" className="p-0 w-[80%]"><Sidebar /></SheetContent>
|
|
</Sheet>
|
|
<h1 className="font-semibold text-lg">Générateur Vidéo</h1>
|
|
</div>
|
|
|
|
<main className="p-4 space-y-4">
|
|
|
|
{/* 1. Images */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base">1. Images</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{/* Input caché pour la caméra */}
|
|
<input
|
|
ref={cameraInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
onChange={handleCameraCapture}
|
|
className="hidden"
|
|
/>
|
|
{/* Input caché pour la galerie - séparé pour meilleure compatibilité mobile */}
|
|
<input
|
|
ref={galleryInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1 h-auto py-4 px-4 flex flex-col items-center gap-1"
|
|
onClick={() => galleryInputRef.current?.click()}
|
|
disabled={totalSelected >= 4}
|
|
>
|
|
<ImagePlus className="h-6 w-6 text-primary" />
|
|
<span className="text-xs font-medium">Galerie</span>
|
|
<span className="text-[10px] text-muted-foreground">{images.length} choisie(s)</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-auto py-4 px-4 flex flex-col items-center gap-1"
|
|
onClick={() => cameraInputRef.current?.click()}
|
|
disabled={totalSelected >= 4}
|
|
>
|
|
<Camera className="h-6 w-6" />
|
|
<span className="text-xs">Photo</span>
|
|
</Button>
|
|
</div>
|
|
{imageMediaList.length > 0 && (
|
|
<div className="flex overflow-x-auto gap-2 pb-1">
|
|
{imageMediaList.map(media => {
|
|
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
|
|
return (
|
|
<div key={media.id} onClick={() => toggleLibraryImage(media)}
|
|
className={`flex-shrink-0 w-16 h-16 relative rounded overflow-hidden border-2 cursor-pointer ${isSelected ? 'border-primary' : 'border-transparent'}`}>
|
|
<MediaThumbnail src={media.originalUrl} alt="" type="image" />
|
|
{isSelected && <div className="absolute top-0.5 right-0.5 bg-primary text-white p-0.5 rounded-full"><Check className="w-2.5 h-2.5" /></div>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-primary font-medium">{totalSelected} / 4 sélectionnée(s)</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 2. Texte & Voix */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<Textarea placeholder="Décrivez votre produit…" value={productInfo}
|
|
onChange={e => setProductInfo(e.target.value)} rows={2} className="text-sm" />
|
|
<Button variant="secondary" size="sm" className="w-full"
|
|
disabled={!productInfo || generateTextMutation.isPending}
|
|
onClick={() => generateTextMutation.mutate(productInfo)}>
|
|
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-3 h-3 animate-spin" /> : <Sparkles className="mr-2 w-3 h-3" />}
|
|
Générer avec l'IA
|
|
</Button>
|
|
{generatedVariants.length > 0 && (
|
|
<div className="space-y-1 max-h-32 overflow-y-auto">
|
|
{generatedVariants.map((v: any, i: number) => (
|
|
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Appliqué" }); }}
|
|
className={`text-xs p-2 rounded border cursor-pointer ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
|
|
{v.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<Textarea placeholder="Texte overlay…" value={overlayText}
|
|
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
|
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Switch id="show-logo-m" checked={showLogo} onCheckedChange={setShowLogo} />
|
|
<Label htmlFor="show-logo-m" className="text-sm">Afficher le logo sur la vidéo</Label>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
|
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
|
|
</div>
|
|
{ttsEnabled && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
|
|
|
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 3. Musique */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="max-h-40 overflow-y-auto space-y-1.5">
|
|
{tracksLoading ? (
|
|
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-primary" /></div>
|
|
) : audioTracks.length === 0 ? (
|
|
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
|
|
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
|
|
<p className="text-xs text-muted-foreground">Aucune musique disponible.<br />Un administrateur peut en ajouter via la Bibliothèque Audio.</p>
|
|
</div>
|
|
) : (
|
|
audioTracks.map(track => {
|
|
const isSelected = selectedTrack?.id === track.id;
|
|
const playing = isPlaying === track.id;
|
|
return (
|
|
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
|
|
className={`flex items-center gap-2 p-2.5 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
|
|
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
|
|
className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
|
|
{playing ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5 ml-0.5" />}
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs font-medium truncate">{track.title}</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
|
</p>
|
|
</div>
|
|
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<div className="relative border-2 border-dashed border-border rounded p-3 flex items-center gap-2">
|
|
<input type="file" accept="audio/*"
|
|
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
|
<UploadCloud className="h-4 w-4 text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground truncate">{musicFile ? musicFile.name : "Ou uploadez votre fichier audio"}</p>
|
|
{musicFile && <Check className="w-3 h-3 text-primary shrink-0" />}
|
|
</div>
|
|
{(musicFile || selectedTrack) && (
|
|
<div className="space-y-1">
|
|
<Label className="flex items-center gap-2 text-xs">
|
|
<Volume2 className="w-3 h-3" /> Volume : {Math.round(musicVolume * 100)}%
|
|
</Label>
|
|
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
|
|
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 4. Destinations */}
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
|
) : (
|
|
<>
|
|
{facebookPages.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
|
Pages Facebook
|
|
</div>
|
|
{facebookPages.map(renderTargetCheckbox)}
|
|
</div>
|
|
)}
|
|
|
|
{tiktokAccounts.length > 0 && (
|
|
<div className="space-y-2 pt-2">
|
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
<SiTiktok className="w-4 h-4" />
|
|
Comptes TikTok
|
|
</div>
|
|
{tiktokAccounts.map(renderTargetCheckbox)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Aperçu en direct */}
|
|
{previewImages.length > 0 && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">Aperçu</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="max-w-[260px] mx-auto">
|
|
<ReelPreview
|
|
kind="images"
|
|
images={previewImages}
|
|
text={overlayText}
|
|
showCaptions
|
|
captionStyle={captionStyle}
|
|
ttsEnabled={ttsEnabled}
|
|
voice={currentVoice}
|
|
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
|
musicVolume={musicVolume}
|
|
logoUrl={reelConfig?.logoUrl}
|
|
showWatermark={showLogo}
|
|
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
|
endingEffect
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Générer */}
|
|
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
|
|
{isRendering
|
|
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
|
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
|
|
</Button>
|
|
|
|
{videoUrl && (
|
|
<div className="space-y-3">
|
|
<p className="font-semibold text-sm text-green-600">Vidéo prête :</p>
|
|
<video src={videoUrl} controls className="w-full rounded-md shadow bg-black" />
|
|
|
|
<Card className="border-primary/40">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="flex items-center gap-2 text-sm">
|
|
<Send className="w-4 h-4" /> Publier sur Facebook
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{facebookPages.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
|
|
) : selectedPageIds.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
|
|
) : (
|
|
<p className="text-xs text-primary font-medium">
|
|
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
|
</p>
|
|
)}
|
|
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
|
onChange={e => setPublishDescription(e.target.value)} />
|
|
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
|
|
occupiedDates={[]} placeholder="Publier immédiatement" />
|
|
<Button className="w-full" onClick={handlePublish}
|
|
disabled={isPublishing || selectedPageIds.length === 0}>
|
|
{isPublishing
|
|
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
|
|
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|