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)