Files
Socialflow/client/src/pages/new-reel.tsx
T
Claude 1d47763b29 feat(reels): interrupteur « Afficher le logo sur la vidéo »
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
2026-09-28 06:45:38 +00:00

1074 lines
59 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import {
Send, Sparkles, Video, Music, Type, Calendar,
Upload, Play, Pause, Volume2, VolumeX,
ChevronRight, Loader2, Check, RefreshCw, Mic
} from "lucide-react";
import { useDropzone } from "react-dropzone";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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 { SrtUpload, type SrtFile } from "@/components/reels/srt-upload";
import { srtText } from "@shared/srt";
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { DateTimePicker } from "@/components/datetime-picker";
interface MusicTrack {
id: string;
title: string;
artist: string;
albumName: string;
duration: number;
previewUrl: string;
downloadUrl: string;
imageUrl: string;
license: string;
}
// Étapes du workflow
type Step = 'video' | 'music' | 'text' | 'publish';
export default function NewReel() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation();
const { toast } = useToast();
const audioRef = useRef<HTMLAudioElement>(null);
// État du workflow
const [currentStep, setCurrentStep] = useState<Step>('video');
// État des données
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
const [overlayText, setOverlayText] = useState('');
// Fichier SRT : remplace le texte libre, la voix suit son minutage
const [srtFile, setSrtFile] = useState<SrtFile | null>(null);
const [productInfo, setProductInfo] = useState('');
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [musicVolume, setMusicVolume] = useState([25]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [drawText, setDrawText] = useState(true);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
// TTS Sync state
const [syncInfo, setSyncInfo] = useState<{
wordDuration: number;
audioDuration: number;
wordCount: number;
isHealthy: boolean;
warnings: string[];
} | null>(null);
// Voix : moteur, voix et ton de lecture
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
style: DEFAULT_TTS_STYLE,
});
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
// Dernière voix générée : l'aperçu l'utilise tant que texte et réglages n'ont pas changé
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
// Enable TTS by default on mobile
useEffect(() => {
const isMobile = window.innerWidth < 768;
if (isMobile) {
setTtsEnabled(true);
}
}, []);
// Auto-calculate TTS sync when text changes
useEffect(() => {
if (!ttsEnabled || !overlayText.trim() || srtFile) {
setSyncInfo(null);
return;
}
const timer = setTimeout(() => {
apiRequest('POST', '/api/reels/sync-info', {
text: overlayText,
ttsVoice,
ttsEngine,
})
.then(r => r.json())
.then(data => setSyncInfo(data))
.catch(() => setSyncInfo(null));
}, 800);
return () => clearTimeout(timer);
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice, srtFile]);
// État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null);
// Récupérer les pages disponibles
const { data: pages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
// Destinations acceptant une vidéo verticale
const facebookPages = pages.filter(p => p.platform === 'facebook');
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center space-x-2">
<Checkbox
id={`page-${page.id}`}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPages([...selectedPages, page.id]);
} else {
setSelectedPages(selectedPages.filter(id => id !== page.id));
}
}}
/>
<label
htmlFor={`page-${page.id}`}
className="text-sm font-medium leading-none flex-1"
>
{page.pageName}
</label>
</div>
);
// Récupérer les vidéos disponibles
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
});
const videoList = allMedia.filter(m => m.type === 'video').slice(0, 12);
// --- Added for Internal Audio Tracks ---
const { data: internalTracksResponse = [], isLoading: internalTracksLoading } = useQuery<any[]>({
queryKey: ['/api/audio-tracks'],
});
const internalTracks: MusicTrack[] = (internalTracksResponse || []).map(t => ({
id: `internal_${t.id}`,
title: t.title,
artist: t.fileName || t.title,
albumName: "Bibliothèque Interne",
duration: t.duration || 0,
previewUrl: t.url,
downloadUrl: t.url,
imageUrl: "",
license: "Internal"
}));
// --- End Internal Tracks ---
// Upload vidéo
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/media/upload", {
method: "POST",
body: formData,
credentials: "include",
});
if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
throw new Error("Upload failed");
}
return response.json();
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
setSelectedVideoId(data.id);
setSelectedVideo(data);
toast({
title: "Succès",
description: "Vidéo téléchargée avec succès",
});
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de télécharger la vidéo",
variant: "destructive",
});
},
});
// Génération de texte IA
const generateTextMutation = useMutation({
mutationFn: async (productInfoText: string) => {
const response = await apiRequest('POST', '/api/reels/generate-text', {
productInfo: productInfoText,
});
return response.json();
},
onSuccess: (data: any) => {
const variants = data.variants || [];
setGeneratedVariants(variants);
toast({
title: "Texte généré",
description: `${variants.length} variations créées`,
});
},
onError: (error: unknown) => {
toast({
title: "Erreur",
description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive",
});
},
});
// Création du Reel
const createReelMutation = useMutation({
mutationFn: async (data: any) => {
const response = await apiRequest('POST', '/api/reels', data);
return response.json();
},
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
toast({
title: "Création du Reel lancée",
description: data.queued
? "Un autre Reel est en cours : le vôtre démarrera juste après. Suivez l'avancement sur le tableau de bord."
: "Suivez l'avancement sur le tableau de bord.",
});
navigate('/');
},
onError: (error: any) => {
toast({
title: "Erreur",
description: error.message || "Impossible de créer le Reel",
variant: "destructive",
});
},
});
const onDrop = useCallback((acceptedFiles: File[], fileRejections: any[]) => {
if (fileRejections.length > 0) {
const rej = fileRejections[0];
const errorMsg = `Erreur: ${rej.errors?.[0]?.message}. Type: ${rej.file.type || 'Inconnu'}. Nom: ${rej.file.name}`;
console.error('❌ Fichiers rejetés:', errorMsg);
// Debug mobile agressif
window.alert("Fichier rejeté !\n" + errorMsg);
toast({
title: "Fichier non supporté",
description: errorMsg,
variant: "destructive",
});
return;
}
if (acceptedFiles.length > 0) {
const file = acceptedFiles[0];
console.log('✅ Fichier accepté:', file.type, file.size);
// Accepter aussi formats iOS sans type MIME standard
if (file.type.startsWith('video/') || file.name.toLowerCase().endsWith('.mov') || file.name.toLowerCase().endsWith('.mp4')) {
uploadMutation.mutate(file);
} else {
toast({
title: "Format invalide",
description: `Fichier non reconnu comme vidéo (${file.type || 'sans type'}). Essayez MP4/MOV.`,
variant: "destructive",
});
}
}
}, []);
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
onDrop,
accept: {
'video/mp4': ['.mp4', '.m4v'],
'video/quicktime': ['.mov', '.qt'],
'video/webm': ['.webm'],
'video/x-msvideo': ['.avi'],
},
multiple: true, // Use multiple to bypass iOS Safari automatic compression
maxSize: 4 * 1024 * 1024 * 1024, // 4GB Limit
noClick: true,
noKeyboard: true,
});
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
uploadMutation.mutate(file);
e.target.value = '';
}
};
const handleSelectVideo = (media: Media) => {
setSelectedVideoId(media.id);
setSelectedVideo(media);
setCurrentStep('music');
};
const handleSelectTrack = (track: MusicTrack) => {
setSelectedTrack(track);
// Arrêter la preview si en cours
if (audioRef.current) {
audioRef.current.pause();
}
setIsPlaying(null);
};
const togglePlayPreview = async (track: MusicTrack) => {
if (isPlaying === track.id) {
audioRef.current?.pause();
setIsPlaying(null);
} else {
if (audioRef.current) {
console.log('🎵 Playing preview:', track.previewUrl);
audioRef.current.src = track.previewUrl;
try {
await audioRef.current.play();
setIsPlaying(track.id);
} catch (error) {
console.error('❌ Audio play error:', error);
toast({
title: "Erreur de lecture",
description: "Impossible de lire la prévisualisation audio. Vérifiez votre connexion.",
variant: "destructive",
});
setIsPlaying(null);
}
}
}
};
const handleGenerateText = () => {
if (!productInfo.trim()) {
toast({
title: "Information manquante",
description: "Veuillez saisir les informations du produit",
variant: "destructive",
});
return;
}
generateTextMutation.mutate(productInfo);
};
const handleUseVariant = (text: string) => {
setOverlayText(text);
toast({
title: "Texte sélectionné",
description: "Le texte a été ajouté à votre Reel",
});
};
const handleCreateReel = () => {
if (!selectedVideoId) {
toast({
title: "Vidéo requise",
description: "Veuillez sélectionner une vidéo",
variant: "destructive",
});
return;
}
if (selectedPages.length === 0) {
toast({
title: "Page requise",
description: "Veuillez sélectionner au moins une page",
variant: "destructive",
});
return;
}
createReelMutation.mutate({
videoMediaId: selectedVideoId,
musicTrackId: selectedTrack?.id,
overlayText: srtFile ? undefined : overlayText,
srtCues: srtFile?.cues,
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
musicVolume: musicVolume[0] / 100,
ttsEnabled,
ttsEngine,
ttsVoice,
ttsStyle,
captionStyle,
drawText,
stabilize: stabilize,
enableEndingEffect,
showLogo,
});
};
const canProceedToMusic = !!selectedVideo;
const canProceedToText = canProceedToMusic; // Musique optionnelle
const canProceedToPublish = canProceedToText;
const canPublish = selectedPages.length > 0 && !!selectedVideo;
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
<Video className="w-8 h-8 text-primary" />
Créer un Reel
</h1>
<p className="text-muted-foreground mt-2">
Créez un Reel Facebook avec musique et texte overlay
</p>
</div>
{/* Stepper */}
<div className="flex items-center justify-between mb-8 px-4">
{(['video', 'music', 'text', 'publish'] as Step[]).map((step, index) => (
<div key={step} className="flex items-center">
<button
onClick={() => {
if (step === 'video') setCurrentStep(step);
else if (step === 'music' && canProceedToMusic) setCurrentStep(step);
else if (step === 'text' && canProceedToText) setCurrentStep(step);
else if (step === 'publish' && canProceedToPublish) setCurrentStep(step);
}}
className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${currentStep === step
? 'bg-primary text-primary-foreground'
: step === 'video' ||
(step === 'music' && canProceedToMusic) ||
(step === 'text' && canProceedToText) ||
(step === 'publish' && canProceedToPublish)
? 'bg-muted hover:bg-accent cursor-pointer'
: 'bg-muted/50 text-muted-foreground cursor-not-allowed'
}`}
>
{step === 'video' && <Video className="w-4 h-4" />}
{step === 'music' && <Music className="w-4 h-4" />}
{step === 'text' && <Type className="w-4 h-4" />}
{step === 'publish' && <Send className="w-4 h-4" />}
<span className="hidden sm:inline capitalize">{step === 'video' ? 'Vidéo' : step === 'music' ? 'Musique' : step === 'text' ? 'Texte' : 'Publier'}</span>
</button>
{index < 3 && <ChevronRight className="w-5 h-5 text-muted-foreground mx-2" />}
</div>
))}
</div>
{/* Contenu par étape */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Colonne principale */}
<div className="lg:col-span-2 space-y-6">
{/* ÉTAPE 1: Vidéo */}
{currentStep === 'video' && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Video className="w-5 h-5" />
Sélectionnez une Vidéo
</CardTitle>
<CardDescription>
Choisissez une vidéo existante ou téléchargez-en une nouvelle
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="p-4 border rounded-xl bg-primary/5 border-primary/20 space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base font-semibold flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
Stabilisation & Qualité 1080p
</Label>
<p className="text-sm text-muted-foreground">
Recommandé pour un rendu professionnel sur Facebook Reels
</p>
<div className="flex items-center gap-1.5 text-[10px] text-primary/70 bg-primary/5 px-2 py-0.5 rounded-full w-fit">
<Sparkles className="w-3 h-3" />
<span>Note iPhone : Activez la stabilisation dans Réglages &gt; Appareil Photo</span>
</div>
</div>
<Switch
checked={stabilize}
onCheckedChange={setStabilize}
/>
</div>
</div>
<div {...getRootProps()} className={`${isDragActive ? 'bg-primary/5 border-primary' : ''}`}>
<input {...getInputProps()} />
<div className="flex gap-2 mb-4">
<Button
onClick={open}
disabled={uploadMutation.isPending}
variant="outline"
>
<Upload className="w-4 h-4 mr-2" />
{uploadMutation.isPending ? 'Upload...' : 'Uploader'}
</Button>
</div>
{videoList.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed rounded-lg">
<Video className="w-12 h-12 mx-auto text-muted-foreground mb-2" />
<p className="text-muted-foreground">
{isDragActive ? "Déposez votre vidéo ici" : "Aucune vidéo disponible"}
</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{videoList.map((media) => (
<button
key={media.id}
onClick={() => handleSelectVideo(media)}
className={`relative aspect-video rounded-lg overflow-hidden border-2 transition-all ${selectedVideoId === media.id
? 'border-primary ring-2 ring-primary'
: 'border-transparent hover:border-muted-foreground'
}`}
>
<MediaThumbnail
src={media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type="video"
/>
{selectedVideoId === media.id && (
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
<Check className="w-8 h-8 text-primary" />
</div>
)}
</button>
))}
</div>
)}
</div>
{selectedVideo && (
<div className="mt-6 flex justify-end">
<Button onClick={() => setCurrentStep('music')}>
Continuer
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* ÉTAPE 2: Musique */}
{currentStep === 'music' && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Music className="w-5 h-5" />
Choisissez une Musique
</CardTitle>
<CardDescription>
Sélectionnez une musique libre de droits (optionnel)
</CardDescription>
</CardHeader>
<CardContent>
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
{/* BIBLIOTHÈQUE INTERNE */}
<div className="space-y-2">
{internalTracksLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : internalTracks.length === 0 ? (
<div className="text-center py-10 border-2 border-dashed border-border rounded-xl">
<Music className="w-12 h-12 mx-auto text-muted-foreground mb-4 opacity-40" />
<p className="text-muted-foreground font-medium">Aucune musique disponible</p>
<p className="text-sm text-muted-foreground mt-1">Un administrateur peut ajouter des MP3 via la Bibliothèque Audio.</p>
</div>
) : (
internalTracks.map((track) => (
<div
key={track.id}
onClick={() => handleSelectTrack(track)}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all ${selectedTrack?.id === track.id
? 'bg-primary/10 border border-primary'
: 'bg-muted/50 hover:bg-muted'
}`}
>
<button
onClick={(e) => {
e.stopPropagation();
togglePlayPreview(track);
}}
className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0"
>
{isPlaying === track.id ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-xs text-muted-foreground truncate">
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
</p>
</div>
{selectedTrack?.id === track.id && (
<Check className="w-5 h-5 text-primary shrink-0" />
)}
</div>
))
)}
</div>
{/* Slider volume + navigation */}
{selectedTrack && (
<div className="mt-4">
<Label className="flex items-center gap-2">
<Volume2 className="w-4 h-4" />
Volume musique: {musicVolume[0]}%
</Label>
<Slider
value={musicVolume}
onValueChange={setMusicVolume}
max={100}
step={5}
className="mt-2"
/>
</div>
)}
<div className="mt-4 flex justify-between">
<Button variant="outline" onClick={() => setCurrentStep('video')}>
Retour
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => {
setSelectedTrack(null);
setCurrentStep('text');
}}>
Passer
</Button>
<Button onClick={() => setCurrentStep('text')}>
Continuer
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* ÉTAPE 3: Texte */}
{currentStep === 'text' && (
<>
{!srtFile && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
Générer avec l'IA
</CardTitle>
<CardDescription>
Décrivez votre produit pour générer des textes
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
value={productInfo}
onChange={(e) => setProductInfo(e.target.value)}
placeholder="Décrivez votre produit : nom, caractéristiques, prix, etc."
rows={4}
/>
<Button
onClick={handleGenerateText}
disabled={generateTextMutation.isPending}
className="w-full"
>
<Sparkles className="w-4 h-4 mr-2" />
{generateTextMutation.isPending ? 'Génération...' : 'Générer 3 variations'}
</Button>
</CardContent>
</Card>
)}
{!srtFile && generatedVariants.length > 0 && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle>Variations générées</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{generatedVariants.map((variant, index) => (
<div
key={index}
className="p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-xs font-semibold text-muted-foreground mb-2">
{variant.variant || `Version ${index + 1}`}
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{variant.text}
</p>
</div>
<Button
size="sm"
onClick={() => handleUseVariant(variant.text)}
>
Utiliser
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Type className="w-5 h-5" />
Texte Overlay
</CardTitle>
<CardDescription>
Ce texte s'affichera au centre de votre Reel (style TikTok).
Vous pouvez aussi importer un fichier SRT : il remplace le texte.
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4">
<SrtUpload value={srtFile} onChange={setSrtFile} />
</div>
{srtFile ? (
<p className="text-xs text-muted-foreground">
Texte libre désactivé : le fichier SRT est utilisé. Retirez-le pour écrire un texte.
</p>
) : (
<Textarea
value={overlayText}
onChange={(e) => setOverlayText(e.target.value)}
placeholder="Écrivez le texte qui apparaîtra sur votre Reel..."
rows={4}
/>
)}
<div className="flex items-center space-x-2 mt-4">
<Switch
id="draw-text"
checked={drawText}
onCheckedChange={setDrawText}
/>
<Label htmlFor="draw-text" className="font-medium cursor-pointer">
Afficher le texte sur la vidéo
</Label>
</div>
{drawText && (
<div className="mt-3 ml-12">
<Label className="text-sm font-medium">Style des sous-titres</Label>
<div className="mt-2">
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
</div>
</div>
)}
<div className="flex items-center space-x-2 mt-4">
<Switch
id="show-logo"
checked={showLogo}
onCheckedChange={setShowLogo}
/>
<Label htmlFor="show-logo" className="font-medium cursor-pointer">
Afficher le logo sur la vidéo
</Label>
</div>
<div className="flex items-center space-x-2 mt-4">
<Switch
id="enable-ending-effect"
checked={enableEndingEffect}
onCheckedChange={setEnableEndingEffect}
/>
<Label htmlFor="enable-ending-effect" className="font-medium cursor-pointer">
Activer l'effet de fin (logo + fondu)
</Label>
</div>
<div className="flex items-center space-x-2 mt-4">
<Switch
id="tts-mode"
checked={ttsEnabled}
onCheckedChange={setTtsEnabled}
/>
<Label htmlFor="tts-mode" className="font-medium cursor-pointer">
Activer la lecture voix (TTS)
</Label>
</div>
{ttsEnabled && (
<div className="mt-4 space-y-2 ml-12 p-4 bg-muted/30 rounded-lg border border-border/50">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mic className="w-4 h-4" />
<span>TTS — voix activée</span>
</div>
<div className="mt-3">
<VoicePicker
value={voiceSettings}
onChange={setVoiceSettings}
sampleText={srtFile ? srtFile.cues[0]?.text : overlayText}
onPreview={setVoicePreview}
/>
</div>
{syncInfo && !srtFile && (
<div className={`p-3 rounded-lg border mt-3 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Sync texte/voix</span>
<span className={syncInfo.isHealthy ? 'text-green-600' : 'text-yellow-600'}>
{syncInfo.wordCount} mots · {syncInfo.audioDuration.toFixed(1)}s · {syncInfo.wordDuration.toFixed(2)}s/mot
</span>
</div>
{syncInfo.warnings.map((w, i) => (
<p key={i} className="text-xs text-yellow-600 mt-1">⚠️ {w}</p>
))}
{syncInfo.isHealthy && syncInfo.warnings.length === 0 && (
<p className="text-xs text-green-600 mt-1">✅ Timing calculé automatiquement</p>
)}
</div>
)}
<p className="text-xs text-muted-foreground mt-2">
{srtFile
? "Chaque sous-titre sera lu à son instant et accéléré si besoin pour respecter la durée du fichier SRT."
: "Le texte sera automatiquement synchronisé avec la voix."}
{' '}Les #hashtags et émojis ne seront pas lus.
</p>
</div>
)}
<div className="mt-4 flex justify-between">
<Button variant="outline" onClick={() => setCurrentStep('music')}>
Retour
</Button>
<Button onClick={() => setCurrentStep('publish')}>
Continuer
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</CardContent>
</Card>
</>
)}
{/* ÉTAPE 4: Publication */}
{currentStep === 'publish' && (
<>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle>Destinations</CardTitle>
<CardDescription>
La même vidéo peut partir sur plusieurs pages Facebook et plusieurs comptes TikTok
</CardDescription>
</CardHeader>
<CardContent>
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-2">
Aucune page Facebook ni compte TikTok connecté
</p>
<Button variant="link" onClick={() => navigate('/pages')}>
Ajouter des pages
</Button>
</div>
) : (
<div className="space-y-5">
{facebookPages.length > 0 && (
<div className="space-y-3">
<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-3">
<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>
)}
</div>
)}
</CardContent>
</Card>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
Planification
</CardTitle>
<CardDescription>
Programmez la publication (optionnel)
</CardDescription>
</CardHeader>
<CardContent>
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
occupiedDates={[]}
placeholder="Publier immédiatement"
/>
</CardContent>
</Card>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setCurrentStep('text')}
className="flex-1"
>
Retour
</Button>
<Button
onClick={handleCreateReel}
disabled={!canPublish || createReelMutation.isPending}
className="flex-1 bg-gradient-to-r from-primary to-secondary hover:opacity-90"
>
{createReelMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Création...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Publier le Reel
</>
)}
</Button>
</div>
</>
)}
</div>
{/* Colonne de prévisualisation */}
<div className="space-y-6">
<Card className="rounded-2xl border-border/50 shadow-lg sticky top-4">
<CardHeader>
<CardTitle>Aperçu</CardTitle>
</CardHeader>
<CardContent>
{selectedVideo ? (
<ReelPreview
kind="video"
videoUrl={selectedVideo.originalUrl}
text={srtFile ? srtText(srtFile.cues) : overlayText}
srtCues={srtFile?.cues}
showCaptions={drawText}
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={selectedTrack?.previewUrl}
musicVolume={musicVolume[0] / 100}
logoUrl={reelConfig?.logoUrl}
showWatermark={showLogo}
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
endingEffect={enableEndingEffect}
/>
) : (
<div className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Video className="w-12 h-12 mx-auto mb-2" />
<p>Sélectionnez une vidéo</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Résumé */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle>Résumé</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Vidéo</span>
<span>{selectedVideo ? '✓' : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Musique</span>
<span>{selectedTrack ? selectedTrack.title : 'Aucune'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Texte</span>
<span>{srtFile ? `SRT (${srtFile.cues.length})` : overlayText ? '✓' : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Pages</span>
<span>{selectedPages.length} sélectionnée(s)</span>
</div>
</CardContent>
</Card>
</div>
</div >
</div >
</main >
</div >
);
}