mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Montage (étape 3) : - Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles : Impact, Surligné, Épuré), logo, effet de fin et fondu, en React - Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière image figée) et la piste son finale (/prepare-reel) ; Remotion compose - Reels d'images sur les mêmes composants, avec le vrai minutage de la voix - Police Montserrat embarquée (plus de dépendance à Google Fonts) - REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours - Script de pré-bundle réparé (échouait en silence : require en ESM) Interface (étape 4) : - Aperçu en direct avec @remotion/player, identique au rendu final ; la voix testée cale les sous-titres, sinon minutage estimé - Choix du style de sous-titres sur les 4 pages Reel - Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur cause ; fin de la barre simulée et du faux « publié avec succès » Outillage (étape 5) : - Tests vitest (minutage identique à Python, validation, sécurité) et pytest ; CI GitHub Actions (tsc, tests, build, ruff) - Captures d'écran, out.mp4 et scripts de test retirés de la racine Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
626 lines
31 KiB
TypeScript
626 lines
31 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,
|
|
ChevronRight, Loader2, Check, RefreshCw, ChevronLeft,
|
|
Menu, Mic
|
|
} from "lucide-react";
|
|
import { useDropzone } from "react-dropzone";
|
|
import Sidebar from "@/components/sidebar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Slider } from "@/components/ui/slider";
|
|
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 { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
|
import type { SocialPage, Media } from "@shared/schema";
|
|
import { SiFacebook, SiTiktok } from "react-icons/si";
|
|
import { DateTimePicker } from "@/components/datetime-picker";
|
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
interface MusicTrack {
|
|
id: string;
|
|
title: string;
|
|
artist: string;
|
|
albumName: string;
|
|
duration: number;
|
|
previewUrl: string;
|
|
downloadUrl: string;
|
|
imageUrl: string;
|
|
license: string;
|
|
}
|
|
|
|
type Step = 'video' | 'music' | 'text' | 'publish';
|
|
|
|
export default function MobileNewReel() {
|
|
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('');
|
|
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]);
|
|
|
|
// État TTS
|
|
const [ttsEnabled, setTtsEnabled] = useState(false);
|
|
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);
|
|
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'] });
|
|
|
|
// État audio preview
|
|
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
|
// 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);
|
|
|
|
// TTS Sync state
|
|
const [syncInfo, setSyncInfo] = useState<{
|
|
wordDuration: number;
|
|
audioDuration: number;
|
|
wordCount: number;
|
|
isHealthy: boolean;
|
|
warnings: string[];
|
|
} | null>(null);
|
|
|
|
// Auto-calculate TTS sync
|
|
useEffect(() => {
|
|
if (!ttsEnabled || !overlayText.trim()) {
|
|
setSyncInfo(null);
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
apiRequest('POST', '/api/reels/sync-info', {
|
|
text: overlayText,
|
|
ttsEngine,
|
|
ttsVoice,
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => setSyncInfo(data))
|
|
.catch(() => setSyncInfo(null));
|
|
}, 800);
|
|
return () => clearTimeout(timer);
|
|
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]);
|
|
|
|
|
|
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-3 bg-secondary/20 p-3 rounded-lg">
|
|
<Checkbox
|
|
id={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.id} className="font-medium flex-1">{page.pageName}</label>
|
|
</div>
|
|
);
|
|
|
|
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 ---
|
|
|
|
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");
|
|
const errorText = await response.text();
|
|
throw new Error(`Upload failed: ${response.status} ${response.statusText} - ${errorText.substring(0, 100)}`);
|
|
}
|
|
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" });
|
|
},
|
|
onError: (error) => {
|
|
console.error("Upload error details:", error);
|
|
toast({
|
|
title: "Erreur Upload",
|
|
description: error.message || "Échec inattendu",
|
|
variant: "destructive",
|
|
duration: 5000
|
|
});
|
|
},
|
|
});
|
|
|
|
const generateTextMutation = useMutation({
|
|
mutationFn: async (productInfoText: string) => {
|
|
const response = await apiRequest('POST', '/api/reels/generate-text', {
|
|
productInfo: productInfoText,
|
|
});
|
|
return response.json();
|
|
},
|
|
onSuccess: (data: any) => {
|
|
setGeneratedVariants(data.variants || []);
|
|
toast({ title: "Généré", description: "Variations créées" });
|
|
},
|
|
});
|
|
|
|
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 l'accueil.",
|
|
});
|
|
navigate('/');
|
|
},
|
|
onError: (error: any) => {
|
|
toast({ title: "Erreur", description: error.message, variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
|
if (acceptedFiles.length > 0 && acceptedFiles[0].type.startsWith('video/')) {
|
|
uploadMutation.mutate(acceptedFiles[0]);
|
|
}
|
|
}, []);
|
|
|
|
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
|
onDrop,
|
|
accept: { "video/*": [".mp4", ".mov", ".webm"] },
|
|
noClick: true,
|
|
noKeyboard: true,
|
|
});
|
|
|
|
|
|
|
|
const togglePlayPreview = async (track: MusicTrack) => {
|
|
if (isPlaying === track.id) {
|
|
audioRef.current?.pause();
|
|
setIsPlaying(null);
|
|
} else {
|
|
if (audioRef.current) {
|
|
audioRef.current.src = track.previewUrl;
|
|
try {
|
|
await audioRef.current.play();
|
|
setIsPlaying(track.id);
|
|
} catch {
|
|
setIsPlaying(null);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleCreateReel = () => {
|
|
if (!selectedVideoId || selectedPages.length === 0) {
|
|
toast({ title: "Erreur", description: "Vidéo et page requises", variant: "destructive" });
|
|
return;
|
|
}
|
|
createReelMutation.mutate({
|
|
videoMediaId: selectedVideoId,
|
|
musicTrackId: selectedTrack?.id,
|
|
overlayText: overlayText,
|
|
description: overlayText,
|
|
pageIds: selectedPages,
|
|
scheduledFor: scheduledDate?.toISOString(),
|
|
musicVolume: musicVolume[0] / 100,
|
|
stabilize: stabilize,
|
|
drawText: true,
|
|
ttsEnabled,
|
|
ttsEngine,
|
|
ttsVoice,
|
|
ttsStyle,
|
|
captionStyle,
|
|
enableEndingEffect,
|
|
});
|
|
};
|
|
|
|
const steps: Step[] = ['video', 'music', 'text', 'publish'];
|
|
const currentStepIndex = steps.indexOf(currentStep);
|
|
|
|
const goNext = () => {
|
|
if (currentStepIndex < steps.length - 1) setCurrentStep(steps[currentStepIndex + 1]);
|
|
};
|
|
|
|
const goBack = () => {
|
|
if (currentStepIndex > 0) setCurrentStep(steps[currentStepIndex - 1]);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background pb-20">
|
|
{/* Header Mobile */}
|
|
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center justify-between">
|
|
<div className="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">Nouveau Reel</h1>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground font-medium">
|
|
{currentStepIndex + 1}/{steps.length}
|
|
</div>
|
|
</div>
|
|
|
|
<main className="p-4 space-y-6">
|
|
{/* VIDEO STEP */}
|
|
{currentStep === 'video' && (
|
|
<div className="space-y-4">
|
|
|
|
<div {...getRootProps()} className={`border-2 border-dashed rounded-xl p-6 text-center ${isDragActive ? 'border-primary bg-primary/5' : 'border-muted'}`}>
|
|
<input {...getInputProps()} />
|
|
|
|
<div className="flex justify-center mb-6">
|
|
<Button onClick={open} variant="outline" size="lg" className="h-12">
|
|
<Upload className="mr-2 h-5 w-5" /> Galerie
|
|
</Button>
|
|
</div>
|
|
|
|
|
|
{selectedVideo ? (
|
|
<div className="relative rounded-lg overflow-hidden aspect-[9/16] bg-black max-h-[50vh] mx-auto shadow-lg">
|
|
<video src={selectedVideo.originalUrl} className="w-full h-full object-contain" controls />
|
|
<div className="absolute top-2 right-2 bg-primary text-white text-xs px-2 py-1 rounded-full flex items-center shadow-sm">
|
|
<Check className="w-3 h-3 mr-1" /> Sélectionné
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-3 mt-4">
|
|
{videoList.map((media) => (
|
|
<div key={media.id} onClick={() => { setSelectedVideoId(media.id); setSelectedVideo(media); }} className={`relative aspect-[9/16] bg-black rounded-lg overflow-hidden border-2 transition-all ${selectedVideoId === media.id ? 'border-primary' : 'border-transparent'}`}>
|
|
<video src={media.originalUrl} className="w-full h-full object-cover" muted />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Card className="bg-primary/5 border-primary/20">
|
|
<CardContent className="p-4 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<div className="text-sm font-medium flex items-center gap-2">
|
|
<Sparkles className="w-4 h-4 text-primary" /> Stabilisation Vidéo (Anti-tremblements)
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">Corrige les secousses (Recommandé main levée). Désactiver sur trépied.</div>
|
|
</div>
|
|
<Switch
|
|
checked={stabilize}
|
|
onCheckedChange={(checked) => setStabilize(!!checked)}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{selectedVideo && (
|
|
<Button className="w-full h-12 text-lg sticky bottom-4 shadow-xl" onClick={goNext}>
|
|
Suivant <ChevronRight className="ml-2 w-5 h-5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* MUSIC STEP */}
|
|
{currentStep === 'music' && (
|
|
<div className="space-y-4">
|
|
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
|
|
|
{selectedVideo && (
|
|
<div className="aspect-video bg-black rounded-lg overflow-hidden h-32 mx-auto">
|
|
<video src={selectedVideo.originalUrl} className="w-full h-full object-cover opacity-50" muted />
|
|
</div>
|
|
)}
|
|
|
|
<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={() => setSelectedTrack(track)} className={`flex items-center gap-3 p-3 rounded-xl border ${selectedTrack?.id === track.id ? 'border-primary bg-primary/5' : 'border-border bg-card'}`}>
|
|
<Button size="icon" variant="ghost" className="h-10 w-10 rounded-full bg-primary/10" onClick={(e) => { e.stopPropagation(); togglePlayPreview(track); }}>
|
|
{isPlaying === track.id ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-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="h-5 w-5 text-primary" />}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{selectedTrack && (
|
|
<div className="bg-card p-4 rounded-xl border mt-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Volume2 className="w-4 h-4 text-primary" />
|
|
<span className="text-sm font-medium">Volume: {musicVolume[0]}%</span>
|
|
</div>
|
|
<Slider value={musicVolume} onValueChange={setMusicVolume} max={100} step={5} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3 sticky bottom-4">
|
|
<Button variant="outline" onClick={goBack} className="flex-1 h-12">Retour</Button>
|
|
<Button onClick={goNext} className="flex-1 h-12">Suivant</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* TEXT STEP */}
|
|
{currentStep === 'text' && (
|
|
<div className="space-y-6">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base flex items-center"><Sparkles className="w-4 h-4 mr-2 text-primary" /> Assistant IA</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Textarea
|
|
placeholder="Décrivez le produit..."
|
|
value={productInfo}
|
|
onChange={(e) => setProductInfo(e.target.value)}
|
|
className="mb-3"
|
|
/>
|
|
<Button onClick={() => generateTextMutation.mutate(productInfo)} disabled={generateTextMutation.isPending} className="w-full" variant="secondary">
|
|
{generateTextMutation.isPending ? <Loader2 className="animate-spin mr-2" /> : <Sparkles className="mr-2 w-4 h-4" />}
|
|
Générer
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{generatedVariants.length > 0 && (
|
|
<div className="space-y-3">
|
|
{generatedVariants.map((v, i) => (
|
|
<div key={i} className="bg-card p-3 rounded-lg border text-sm" onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}>
|
|
{v.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Texte Overlay</label>
|
|
<Textarea
|
|
value={overlayText}
|
|
onChange={(e) => setOverlayText(e.target.value)}
|
|
placeholder="Texte sur la vidéo..."
|
|
className="text-lg"
|
|
rows={3}
|
|
/>
|
|
<div className="space-y-1.5 mt-3">
|
|
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
|
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
|
</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-1 p-3 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-2">
|
|
<VoicePicker
|
|
value={voiceSettings}
|
|
onChange={setVoiceSettings}
|
|
sampleText={overlayText}
|
|
onPreview={setVoicePreview}
|
|
compact
|
|
/>
|
|
</div>
|
|
|
|
{syncInfo && (
|
|
<div className={`p-2 rounded-lg border mt-2 ${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-xs">
|
|
<span className="font-medium">Sync</span>
|
|
<span className={syncInfo.isHealthy ? 'text-green-600' : 'text-yellow-600'}>
|
|
{syncInfo.wordCount} mots · {syncInfo.audioDuration.toFixed(1)}s
|
|
</span>
|
|
</div>
|
|
{syncInfo.warnings.map((w, i) => (
|
|
<p key={i} className="text-[10px] text-yellow-600 mt-0.5">⚠️ {w}</p>
|
|
))}
|
|
{syncInfo.isHealthy && syncInfo.warnings.length === 0 && (
|
|
<p className="text-[10px] text-green-600 mt-0.5">✅ Timing OK</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3 sticky bottom-4">
|
|
<Button variant="outline" onClick={goBack} className="flex-1 h-12">Retour</Button>
|
|
<Button onClick={goNext} className="flex-1 h-12">Suivant</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* PUBLISH STEP */}
|
|
{currentStep === 'publish' && (
|
|
<div className="space-y-6">
|
|
{selectedVideo && (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">Aperçu</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="max-w-[260px] mx-auto">
|
|
<ReelPreview
|
|
kind="video"
|
|
videoUrl={selectedVideo.originalUrl}
|
|
text={overlayText}
|
|
showCaptions
|
|
captionStyle={captionStyle}
|
|
ttsEnabled={ttsEnabled}
|
|
voice={currentVoice}
|
|
musicUrl={selectedTrack?.previewUrl}
|
|
musicVolume={musicVolume[0] / 100}
|
|
logoUrl={reelConfig?.logoUrl}
|
|
storeName={pages.find((p) => p.id === selectedPages[0])?.pageName}
|
|
endingEffect={enableEndingEffect}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">Diffusion</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Aucune page Facebook ni compte TikTok connecté.
|
|
</p>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base flex items-center"><Calendar className="mr-2 w-4 h-4" /> Planification</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DateTimePicker value={scheduledDate} onChange={setScheduledDate} placeholder="Maintenant" />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex gap-3 sticky bottom-4 pb-4 bg-background">
|
|
<Button variant="outline" onClick={goBack} className="flex-1 h-12">Retour</Button>
|
|
<Button
|
|
onClick={handleCreateReel}
|
|
disabled={createReelMutation.isPending}
|
|
className="flex-[2] h-12 bg-primary text-primary-foreground font-bold shadow-lg"
|
|
>
|
|
{createReelMutation.isPending ? <Loader2 className="animate-spin mr-2" /> : <Send className="mr-2 w-5 h-5" />}
|
|
{scheduledDate ? 'Programmer' : 'Publier'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|