mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 14:38:11 +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
1074 lines
59 KiB
TypeScript
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 > 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 >
|
|
);
|
|
}
|