From 56210687a26dfc5afb62727e093b549985e09900 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 25 Mar 2026 16:36:57 +0100 Subject: [PATCH] feat: implement Facebook posting service, add new sidebar component, and introduce Remotion server route. --- client/src/components/sidebar.tsx | 213 +++++++++--------------------- server/routes/remotion.ts | 2 +- server/services/facebook.ts | 43 +++++- 3 files changed, 107 insertions(+), 151 deletions(-) diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx index 2ec4690..b88f4cd 100644 --- a/client/src/components/sidebar.tsx +++ b/client/src/components/sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2, BarChart3, Video, Music } from "lucide-react"; +import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react"; import { Link, useLocation } from "wouter"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiRequest, queryClient } from "@/lib/queryClient"; @@ -19,11 +19,19 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) { const saved = localStorage.getItem('sidebar-collapsed'); return saved === 'true'; }); + const [adminExpanded, setAdminExpanded] = useState(() => { + const saved = localStorage.getItem('sidebar-admin-expanded'); + return saved !== 'false'; // default: expanded + }); useEffect(() => { localStorage.setItem('sidebar-collapsed', String(isCollapsed)); }, [isCollapsed]); + useEffect(() => { + localStorage.setItem('sidebar-admin-expanded', String(adminExpanded)); + }, [adminExpanded]); + const { data: session } = useQuery<{ id: string; username: string; role: string }>({ queryKey: ["/api/auth/session"], retry: false, @@ -186,155 +194,64 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
{isAdmin && ( <> - {!isCollapsed && ( -
-

Administration

+ {/* Admin section toggle */} + + + {/* Admin links — collapsible */} + {(adminExpanded || isCollapsed) && ( +
+ {[ + { href: "/pages", icon: Users, label: "Pages gérées", testId: "link-pages-gérées" }, + { href: "/ai", icon: Bot, label: "Assistant IA", testId: "link-assistant-ia" }, + { href: "/users", icon: UserCog, label: "Utilisateurs", testId: "link-users" }, + { href: "/sql", icon: Database,label: "SQL", testId: "link-sql" }, + { href: "/audio-admin", icon: Music, label: "Bibliothèque Audio", testId: "link-audio-admin" }, + { href: "/settings", icon: Settings,label: "Paramètres", testId: "link-settings" }, + ].map(({ href, icon: Icon, label, testId }) => ( + handleLinkClick(href, e)} + className={` + flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all relative group cursor-pointer + ${location === href + ? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm' + : 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground' + } + ${isCollapsed ? 'justify-center' : ''} + `} + data-testid={testId} + > + {location === href && ( +
+ )} + + {!isCollapsed && {label}} + {isCollapsed && ( +
+ {label} +
+ )} +
+ ))}
)} - handleLinkClick("/pages", e)} - className={` - flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer - ${location === "/pages" - ? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm' - : 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground' - } - ${isCollapsed ? 'justify-center' : ''} - `} - data-testid="link-pages-gérées" - > - {location === "/pages" && ( -
- )} - - {!isCollapsed && Pages gérées} - {isCollapsed && ( -
- Pages gérées -
- )} -
- handleLinkClick("/ai", e)} - className={` - flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer - ${location === "/ai" - ? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm' - : 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground' - } - ${isCollapsed ? 'justify-center' : ''} - `} - data-testid="link-assistant-ia" - > - {location === "/ai" && ( -
- )} - - {!isCollapsed && Assistant IA} - {isCollapsed && ( -
- Assistant IA -
- )} -
- handleLinkClick("/users", e)} - className={` - flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer - ${location === "/users" - ? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm' - : 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground' - } - ${isCollapsed ? 'justify-center' : ''} - `} - data-testid="link-users" - > - {location === "/users" && ( -
- )} - - {!isCollapsed && Utilisateurs} - {isCollapsed && ( -
- Utilisateurs -
- )} -
- handleLinkClick("/sql", e)} - className={` - flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer - ${location === "/sql" - ? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm' - : 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground' - } - ${isCollapsed ? 'justify-center' : ''} - `} - data-testid="link-sql" - > - {location === "/sql" && ( -
- )} - - {!isCollapsed && SQL} - {isCollapsed && ( -
- SQL -
- )} -
- handleLinkClick("/audio-admin", e)} - className={` - flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer - ${location === "/audio-admin" - ? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm' - : 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground' - } - ${isCollapsed ? 'justify-center' : ''} - `} - data-testid="link-audio-admin" - > - {location === "/audio-admin" && ( -
- )} - - {!isCollapsed && Bibliothèque Audio} - {isCollapsed && ( -
- Bibliothèque Audio -
- )} -
- handleLinkClick("/settings", e)} - className={` - flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative group cursor-pointer - ${location === "/settings" - ? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm' - : 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground' - } - ${isCollapsed ? 'justify-center' : ''} - `} - data-testid="link-settings" - > - {location === "/settings" && ( -
- )} - - {!isCollapsed && Paramètres} - {isCollapsed && ( -
- Paramètres -
- )} -
)} diff --git a/server/routes/remotion.ts b/server/routes/remotion.ts index bc93522..e10b41b 100644 --- a/server/routes/remotion.ts +++ b/server/routes/remotion.ts @@ -375,7 +375,7 @@ remotionRouter.post("/publish", async (req, res) => { // Upload video bytes directly — Facebook cannot access local URLs const videoBuffer = await fs.promises.readFile(localPath); const arrayBuffer = videoBuffer.buffer.slice(videoBuffer.byteOffset, videoBuffer.byteOffset + videoBuffer.byteLength) as ArrayBuffer; - const reelId = await facebookService.publishReelFromBuffer(page, arrayBuffer, description || ""); + const reelId = await facebookService.publishVideoFromBuffer(page, arrayBuffer, description || ""); await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId }); results.push({ pageId, success: true, reelId }); } else { diff --git a/server/services/facebook.ts b/server/services/facebook.ts index 4b52f1f..781b67e 100644 --- a/server/services/facebook.ts +++ b/server/services/facebook.ts @@ -79,7 +79,7 @@ export class FacebookService { : videoMedia.originalUrl; const nodeBuffer = await fs.promises.readFile(localPath); const arrayBuffer = nodeBuffer.buffer.slice(nodeBuffer.byteOffset, nodeBuffer.byteOffset + nodeBuffer.byteLength) as ArrayBuffer; - return await this.publishReelFromBuffer(page, arrayBuffer, post.content); + return await this.publishVideoFromBuffer(page, arrayBuffer, post.content); } else { // Default to feed const imageMedia = mediaList.filter(m => m.type === 'image'); @@ -561,10 +561,49 @@ export class FacebookService { return postId; } + /** + * Publie une vidéo sur Facebook en uploadant les données binaires via multipart. + * Utilise le même endpoint /videos que publishReel mais sans URL publique requise. + * Plus compatible que publishReelFromBuffer (pas besoin des permissions Reels API). + */ + async publishVideoFromBuffer( + page: SocialPage, + videoBuffer: ArrayBuffer, + description?: string + ): Promise { + if (!page.accessToken) throw new Error('No access token found for this page'); + + console.log('🎬 Publishing video via multipart to /videos:', { + pageId: page.pageId, + pageName: page.pageName, + size: `${(videoBuffer.byteLength / 1024 / 1024).toFixed(2)} MB`, + }); + + const formData = new FormData(); + formData.append('access_token', page.accessToken); + if (description) formData.append('description', description); + formData.append('source', new Blob([videoBuffer], { type: 'video/mp4' }), 'video.mp4'); + + const response = await fetch(`${this.baseUrl}/${page.pageId}/videos`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const error = await response.json() as FacebookError; + throw new Error(`Facebook API error publishing video: ${error.error.message} (code: ${error.error.code})`); + } + + const data = await response.json() as FacebookVideoResponse; + const postId = data.post_id || data.id; + console.log('✅ Video published via multipart! ID:', postId); + return postId; + } + /** * Publie une vidéo comme Reel Facebook en uploadant directement les données binaires * Utile quand on a la vidéo en base64 (après traitement FFmpeg) - * + * * @param page - Page Facebook cible * @param videoBuffer - Buffer de la vidéo * @param description - Description du Reel (optionnel)