mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 22:48:09 +02:00
feat(reels): add real-time generation progress tracking on dashboard (desktop & mobile)
This commit is contained in:
1 parent
11b5ef0ad2
commit
e1c6b51e0a
7 files changed
+209
-6
No files matched your search
@@ -0,0 +1,109 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
import type { Post } from "@shared/schema";
|
||||||
|
|
||||||
|
/** Labels for each progress stage */
|
||||||
|
function getStageLabel(progress: number): string {
|
||||||
|
if (progress <= 5) return "Démarrage du traitement…";
|
||||||
|
if (progress <= 15) return "Traitement vidéo (FFmpeg)…";
|
||||||
|
if (progress <= 65) return "Encodage en cours…";
|
||||||
|
if (progress <= 85) return "Upload vers le cloud…";
|
||||||
|
if (progress <= 90) return "Liaison du média…";
|
||||||
|
if (progress <= 95) return "Publication en cours…";
|
||||||
|
return "Finalisation…";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OngoingReelsProps {
|
||||||
|
/** Compact layout for mobile */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||||
|
const { data: ongoingPosts = [] } = useQuery<Post[]>({
|
||||||
|
queryKey: ["/api/reels/ongoing"],
|
||||||
|
refetchInterval: 3000, // Poll every 3 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing to show — render nothing (not even a card)
|
||||||
|
if (ongoingPosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`rounded-2xl border-border/50 shadow-lg ${compact ? "" : ""
|
||||||
|
}`}
|
||||||
|
data-testid="card-ongoing-reels"
|
||||||
|
>
|
||||||
|
<CardHeader className={compact ? "pb-2 px-4 pt-4" : ""}>
|
||||||
|
<CardTitle className={`flex items-center gap-2 ${compact ? "text-base" : ""}`}>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||||
|
Génération en cours
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={compact ? "px-4 pb-4 pt-0" : ""}>
|
||||||
|
<div className={`space-y-${compact ? "3" : "4"}`}>
|
||||||
|
{ongoingPosts.map((post) => {
|
||||||
|
const progress = post.generationProgress ?? 0;
|
||||||
|
const isFailed = post.generationStatus === "failed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={post.id}
|
||||||
|
className={`flex items-start gap-3 p-${compact ? "3" : "4"} rounded-lg border border-border/50 bg-muted/30`}
|
||||||
|
data-testid={`ongoing-reel-${post.id}`}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-violet-500 to-fuchsia-500 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Clapperboard className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`font-medium truncate ${compact ? "text-sm" : "text-sm"}`}>
|
||||||
|
{post.content || "Reel sans texte"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isFailed ? (
|
||||||
|
<div className="flex items-center gap-1.5 mt-2 text-destructive">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
<span className="text-xs">
|
||||||
|
{post.generationError || "Erreur lors du traitement"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{getStageLabel(progress)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-primary">
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{progress >= 100 && !isFailed && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-2 text-green-500">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-medium">Terminé</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
import StatsCards from "@/components/stats-cards";
|
import StatsCards from "@/components/stats-cards";
|
||||||
|
import OngoingReels from "@/components/ongoing-reels";
|
||||||
import RecentPublications from "@/components/recent-publications";
|
import RecentPublications from "@/components/recent-publications";
|
||||||
import ManagedPages from "@/components/managed-pages";
|
import ManagedPages from "@/components/managed-pages";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -11,12 +12,12 @@ export default function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`
|
<div className={`
|
||||||
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
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'}
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||||
@@ -26,12 +27,14 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||||
|
|
||||||
<div className="p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8 max-w-[1600px] mx-auto">
|
<div className="p-4 sm:p-6 lg:p-8 space-y-6 sm:space-y-8 max-w-[1600px] mx-auto">
|
||||||
<StatsCards />
|
<StatsCards />
|
||||||
|
|
||||||
|
<OngoingReels />
|
||||||
|
|
||||||
<RecentPublications />
|
<RecentPublications />
|
||||||
|
|
||||||
<ManagedPages />
|
<ManagedPages />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
import StatsCards from "@/components/stats-cards";
|
import StatsCards from "@/components/stats-cards";
|
||||||
|
import OngoingReels from "@/components/ongoing-reels";
|
||||||
import RecentPublications from "@/components/recent-publications";
|
import RecentPublications from "@/components/recent-publications";
|
||||||
import ManagedPages from "@/components/managed-pages";
|
import ManagedPages from "@/components/managed-pages";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -31,6 +32,8 @@ export default function DashboardMobile() {
|
|||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<StatsCards />
|
<StatsCards />
|
||||||
|
|
||||||
|
<OngoingReels compact />
|
||||||
|
|
||||||
<RecentPublications />
|
<RecentPublications />
|
||||||
|
|
||||||
<ManagedPages />
|
<ManagedPages />
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ export async function migrate() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// posts.generation_status (Reel progress tracking)
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "posts"
|
||||||
|
ADD COLUMN IF NOT EXISTS "generation_status" text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// posts.generation_progress
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "posts"
|
||||||
|
ADD COLUMN IF NOT EXISTS "generation_progress" integer DEFAULT 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// posts.generation_error
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "posts"
|
||||||
|
ADD COLUMN IF NOT EXISTS "generation_error" text;
|
||||||
|
`);
|
||||||
|
|
||||||
console.log("[Migration] Safe migration completed.");
|
console.log("[Migration] Safe migration completed.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Migration] Error during migration:", error);
|
console.error("[Migration] Error during migration:", error);
|
||||||
|
|||||||
+44
-1
@@ -417,7 +417,17 @@ async function processReelBackground(
|
|||||||
|
|
||||||
console.log(`🔄 [Background] Starting processing for Post ${postId}`);
|
console.log(`🔄 [Background] Starting processing for Post ${postId}`);
|
||||||
|
|
||||||
|
// Helper to update generation progress
|
||||||
|
const updateProgress = async (progress: number, status: string = 'processing') => {
|
||||||
|
try {
|
||||||
|
await storage.updatePostGenerationStatus(postId, status, progress);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`⚠️ [Background] Failed to update progress for ${postId}:`, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await updateProgress(5);
|
||||||
// 1. Récupérer le média vidéo source
|
// 1. Récupérer le média vidéo source
|
||||||
const media = await storage.getMediaById(videoMediaId);
|
const media = await storage.getMediaById(videoMediaId);
|
||||||
if (!media || media.type !== 'video') {
|
if (!media || media.type !== 'video') {
|
||||||
@@ -441,6 +451,7 @@ async function processReelBackground(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. Traiter la vidéo via FFmpeg
|
// 3. Traiter la vidéo via FFmpeg
|
||||||
|
await updateProgress(15);
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const ffmpegResult = await ffmpegService.processReelFromUrl(media.originalUrl, {
|
const ffmpegResult = await ffmpegService.processReelFromUrl(media.originalUrl, {
|
||||||
text: overlayText,
|
text: overlayText,
|
||||||
@@ -461,6 +472,7 @@ async function processReelBackground(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Upload sur Cloudinary
|
// 4. Upload sur Cloudinary
|
||||||
|
await updateProgress(65);
|
||||||
console.log('☁️ [Background] Uploading to Cloudinary...');
|
console.log('☁️ [Background] Uploading to Cloudinary...');
|
||||||
const videoBuffer = Buffer.from(ffmpegResult.videoBase64, 'base64');
|
const videoBuffer = Buffer.from(ffmpegResult.videoBase64, 'base64');
|
||||||
const cloudinaryResult = await cloudinaryService.uploadMedia(
|
const cloudinaryResult = await cloudinaryService.uploadMedia(
|
||||||
@@ -486,8 +498,10 @@ async function processReelBackground(
|
|||||||
|
|
||||||
// 6. Lier le média traité au Post existant
|
// 6. Lier le média traité au Post existant
|
||||||
await storage.updatePostMedia(postId, [processedMedia.id]);
|
await storage.updatePostMedia(postId, [processedMedia.id]);
|
||||||
|
await updateProgress(85);
|
||||||
|
|
||||||
// 7. Publier sur les pages
|
// 7. Publier sur les pages
|
||||||
|
await updateProgress(90);
|
||||||
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
|
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
|
||||||
|
|
||||||
for (const pageId of pageIds) {
|
for (const pageId of pageIds) {
|
||||||
@@ -562,6 +576,7 @@ async function processReelBackground(
|
|||||||
await storage.updatePost(postId, {
|
await storage.updatePost(postId, {
|
||||||
status: finalStatus,
|
status: finalStatus,
|
||||||
});
|
});
|
||||||
|
await updateProgress(100, 'completed');
|
||||||
|
|
||||||
console.log(`✅ [Background] Processing complete for Post ${postId}. Status: ${finalStatus}`);
|
console.log(`✅ [Background] Processing complete for Post ${postId}. Status: ${finalStatus}`);
|
||||||
|
|
||||||
@@ -570,6 +585,14 @@ async function processReelBackground(
|
|||||||
await storage.updatePost(postId, {
|
await storage.updatePost(postId, {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
await storage.updatePostGenerationStatus(
|
||||||
|
postId, 'failed', 0,
|
||||||
|
error?.message || 'Erreur inconnue lors du traitement'
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`⚠️ [Background] Failed to update error status for ${postId}:`, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,8 +635,10 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
content: description || overlayText || '',
|
content: description || overlayText || '',
|
||||||
aiGenerated: 'false',
|
aiGenerated: 'false',
|
||||||
status: scheduledFor ? 'scheduled' : 'draft', // 'draft' servira de 'processing' temporaire
|
status: scheduledFor ? 'scheduled' : 'draft',
|
||||||
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
|
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
|
||||||
|
generationStatus: 'processing',
|
||||||
|
generationProgress: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✨ Reel Request accepted. Post ID: ${post.id}. Starting background processing.`);
|
console.log(`✨ Reel Request accepted. Post ID: ${post.id}. Starting background processing.`);
|
||||||
@@ -661,3 +686,21 @@ reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
|||||||
res.status(500).json({ error: 'Erreur lors de la récupération de la configuration' });
|
res.status(500).json({ error: 'Erreur lors de la récupération de la configuration' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reels en cours de génération
|
||||||
|
* GET /api/reels/ongoing
|
||||||
|
*/
|
||||||
|
reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const allPosts = await storage.getPosts(user.id);
|
||||||
|
const ongoing = allPosts.filter(
|
||||||
|
(p) => p.generationStatus === 'processing' || p.generationStatus === 'pending'
|
||||||
|
);
|
||||||
|
res.json(ongoing);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error fetching ongoing reels:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur lors de la récupération des reels en cours' });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -68,6 +68,7 @@ export interface IStorage {
|
|||||||
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
||||||
createPost(post: InsertPost): Promise<Post>;
|
createPost(post: InsertPost): Promise<Post>;
|
||||||
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
||||||
|
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
|
||||||
deletePost(id: string): Promise<void>;
|
deletePost(id: string): Promise<void>;
|
||||||
updatePostMedia(postId: string, mediaIds: string[]): Promise<void>;
|
updatePostMedia(postId: string, mediaIds: string[]): Promise<void>;
|
||||||
|
|
||||||
@@ -257,6 +258,28 @@ export class DatabaseStorage implements IStorage {
|
|||||||
await db.delete(posts).where(eq(posts.id, id));
|
await db.delete(posts).where(eq(posts.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updatePostGenerationStatus(
|
||||||
|
id: string,
|
||||||
|
status: string,
|
||||||
|
progress: number,
|
||||||
|
error?: string
|
||||||
|
): Promise<Post> {
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
generationStatus: status,
|
||||||
|
generationProgress: progress,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (error !== undefined) {
|
||||||
|
updateData.generationError = error;
|
||||||
|
}
|
||||||
|
const [updated] = await db
|
||||||
|
.update(posts)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(posts.id, id))
|
||||||
|
.returning();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async updatePostMedia(postId: string, mediaIds: string[]): Promise<void> {
|
async updatePostMedia(postId: string, mediaIds: string[]): Promise<void> {
|
||||||
await db.delete(postMedia).where(eq(postMedia.postId, postId));
|
await db.delete(postMedia).where(eq(postMedia.postId, postId));
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ export const posts = pgTable("posts", {
|
|||||||
productInfo: jsonb("product_info"),
|
productInfo: jsonb("product_info"),
|
||||||
status: postStatusEnum("status").notNull().default("draft"),
|
status: postStatusEnum("status").notNull().default("draft"),
|
||||||
scheduledFor: timestamp("scheduled_for"),
|
scheduledFor: timestamp("scheduled_for"),
|
||||||
|
// Reel generation tracking
|
||||||
|
generationStatus: text("generation_status"), // 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
|
generationProgress: integer("generation_progress").default(0),
|
||||||
|
generationError: text("generation_error"),
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user