diff --git a/.env.example b/.env.example index cfca62a..82ee91c 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,42 @@ MINIO_PUBLIC_URL=https://media.example.com # INSTAGRAM_APP_ID= # INSTAGRAM_APP_SECRET= +# ========================================== +# TIKTOK (publication des reels) +# ========================================== +# Identifiants de votre application sur https://developers.tiktok.com +# Ils peuvent aussi être saisis dans l'application (Paramètres → TikTok), +# ce qui est prioritaire sur ces variables. +# TIKTOK_CLIENT_KEY= +# TIKTOK_CLIENT_SECRET= +# +# URL de redirection OAuth à déclarer chez TikTok. +# Par défaut : ${APP_URL}/api/tiktok/callback +# TIKTOK_REDIRECT_URI= +# +# Scopes demandés à l'autorisation (défaut : user.info.basic,video.publish). +# "video.publish" nécessite que l'application ait passé l'audit TikTok ; +# sans audit, les vidéos publiées restent privées. +# TIKTOK_SCOPES= + +# ========================================== +# MENTIONS LÉGALES +# ========================================== +# Affichées sur les pages publiques /terms et /privacy, dont TikTok exige +# les URLs pour l'audit de l'application développeur. +# +# L'identité de la société exploitante est déjà la valeur par défaut : seule +# l'adresse de contact doit obligatoirement être renseignée ici, le RGPD +# imposant un point de contact pour l'exercice des droits. +LEGAL_CONTACT_EMAIL= + +# Surcharges facultatives (utiles si l'outil est exploité par une autre société) +# LEGAL_COMPANY_NAME= +# LEGAL_TRADE_NAME= +# LEGAL_COMPANY_LEGAL_FORM= +# LEGAL_COMPANY_RCS= +# LEGAL_COMPANY_ADDRESS= + # ========================================== # API EXTERNE # ========================================== diff --git a/client/src/components/calendar-list-view.tsx b/client/src/components/calendar-list-view.tsx index 9cb2fad..18e450e 100644 --- a/client/src/components/calendar-list-view.tsx +++ b/client/src/components/calendar-list-view.tsx @@ -1,7 +1,7 @@ import { format, isSameDay, parseISO } from "date-fns"; import { fr } from "date-fns/locale"; import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react"; -import { SiFacebook, SiInstagram } from "react-icons/si"; +import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si"; import { Button } from "@/components/ui/button"; import { useState } from "react"; @@ -159,7 +159,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP const isPublished = !!post.publishedAt; const pageName = post.page?.pageName || 'Page inconnue'; const platform = post.page?.platform || 'facebook'; - const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; + const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook; return (
@@ -203,6 +204,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
{page.platform === "facebook" ? ( + ) : page.platform === "tiktok" ? ( + ) : ( )} @@ -65,7 +67,7 @@ export default function ManagedPages() {

{page.pageName}

- {page.platform === "facebook" ? "Facebook Page" : "Instagram Business"} + {page.platform === "facebook" ? "Facebook Page" : page.platform === "tiktok" ? "Compte TikTok" : "Instagram Business"}

diff --git a/client/src/components/media-thumbnail.tsx b/client/src/components/media-thumbnail.tsx index 810a7a0..996203c 100644 --- a/client/src/components/media-thumbnail.tsx +++ b/client/src/components/media-thumbnail.tsx @@ -61,7 +61,10 @@ export function MediaThumbnail({ src, alt, type, className = "w-full h-full obje }; const handleError = () => { - console.warn('Video thumbnail generation failed for:', src); + // Cas courant et attendu : la vidéo a été supprimée du disque après + // publication. Les médias créés depuis embarquent une vignette persistée, + // qui évite d'en arriver là. + console.warn('Aperçu vidéo indisponible (fichier absent ou illisible):', src); setError(true); video.remove(); }; diff --git a/client/src/components/media-upload.tsx b/client/src/components/media-upload.tsx index 5eac0af..dc284bc 100644 --- a/client/src/components/media-upload.tsx +++ b/client/src/components/media-upload.tsx @@ -284,6 +284,7 @@ export default function MediaUpload() {
diff --git a/client/src/components/recent-publications.tsx b/client/src/components/recent-publications.tsx index db2e699..6b915ad 100644 --- a/client/src/components/recent-publications.tsx +++ b/client/src/components/recent-publications.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react"; +import { SiTiktok } from "react-icons/si"; import { format } from "date-fns"; import { fr } from "date-fns/locale"; import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema"; @@ -162,10 +163,14 @@ export default function RecentPublications() { {/* Platform icon */}
{scheduledPost.page?.platform === 'facebook' ? ( + ) : scheduledPost.page?.platform === 'tiktok' ? ( + ) : ( )} diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index 56f2fae..aac81aa 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -20,6 +20,29 @@ export function handleUnauthorized(url: string) { window.location.href = "/login"; } +/** + * Extrait un message lisible d'une erreur d'API. + * apiRequest lève des erreurs de la forme `500: {"error":"…"}` : sans ce + * décodage, l'interface ne peut afficher qu'un message générique alors que le + * serveur a renvoyé la cause exacte. + */ +export function getErrorMessage(error: unknown, fallback: string): string { + if (!(error instanceof Error) || !error.message) return fallback; + + const withoutStatus = error.message.replace(/^\d{3}:\s*/, '').trim(); + if (!withoutStatus) return fallback; + + try { + const parsed = JSON.parse(withoutStatus); + const message = parsed?.error || parsed?.message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } catch { + // Corps non-JSON : on affiche le texte tel quel + } + + return withoutStatus.startsWith('<') ? fallback : withoutStatus; +} + async function throwIfResNotOk(res: Response, url: string) { if (!res.ok) { if (res.status === 401) { diff --git a/client/src/pages/mobile/new-post.tsx b/client/src/pages/mobile/new-post.tsx index d463e79..8ed87f1 100644 --- a/client/src/pages/mobile/new-post.tsx +++ b/client/src/pages/mobile/new-post.tsx @@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; +import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import { PreviewModal } from "@/components/preview-modal"; import { DateTimePicker } from "@/components/datetime-picker"; @@ -81,6 +81,7 @@ function SortableMediaItem({ @@ -119,9 +120,12 @@ export default function NewPostMobile() { const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed'); const [previewModalOpen, setPreviewModalOpen] = useState(false); - const { data: pages = [] } = useQuery({ + // Ce composeur gère les publications feed/story : TikTok n'accepte que des + // vidéos et se pilote depuis la création de reel. + const { data: allPages = [] } = useQuery({ queryKey: ['/api/pages'], }); + const pages = allPages.filter(p => p.platform !== 'tiktok'); const { data: allMedia = [] } = useQuery({ queryKey: ['/api/media'], @@ -227,10 +231,10 @@ export default function NewPostMobile() { }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); }, - onError: () => { + onError: (error: unknown) => { toast({ title: "Erreur", - description: "Impossible de générer le texte", + description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive", }); }, @@ -532,6 +536,7 @@ export default function NewPostMobile() { diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx index ba86893..20e39c2 100644 --- a/client/src/pages/mobile/new-reel.tsx +++ b/client/src/pages/mobile/new-reel.tsx @@ -18,6 +18,7 @@ import { Slider } from "@/components/ui/slider"; import { useToast } from "@/hooks/use-toast"; 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"; @@ -114,6 +115,24 @@ export default function MobileNewReel() { 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) => ( +
+ { + if (checked) setSelectedPages([...selectedPages, page.id]); + else setSelectedPages(selectedPages.filter(id => id !== page.id)); + }} + /> + +
+ ); + const { data: allMedia = [] } = useQuery({ queryKey: ['/api/media'], }); @@ -585,20 +604,32 @@ export default function MobileNewReel() { Diffusion - - {pages.filter(p => p.platform === 'facebook').map((page) => ( -
- { - if (checked) setSelectedPages([...selectedPages, page.id]); - else setSelectedPages(selectedPages.filter(id => id !== page.id)); - }} - /> - + + {facebookPages.length === 0 && tiktokAccounts.length === 0 && ( +

+ Aucune page Facebook ni compte TikTok connecté. +

+ )} + + {facebookPages.length > 0 && ( +
+
+ + Pages Facebook +
+ {facebookPages.map(renderTargetCheckbox)}
- ))} + )} + + {tiktokAccounts.length > 0 && ( +
+
+ + Comptes TikTok +
+ {tiktokAccounts.map(renderTargetCheckbox)} +
+ )}
diff --git a/client/src/pages/mobile/pages.tsx b/client/src/pages/mobile/pages.tsx index b3072b0..9bcc59e 100644 --- a/client/src/pages/mobile/pages.tsx +++ b/client/src/pages/mobile/pages.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react"; +import { SiTiktok } from "react-icons/si"; import Sidebar from "@/components/sidebar"; import TopBar from "@/components/topbar"; import { Button } from "@/components/ui/button"; @@ -66,12 +67,73 @@ function getTokenExpirationStatus(expiresAt: string | null | undefined) { } } +/** + * Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la + * validité de l'autorisation, portée par le refresh token (1 an). + */ +function getConnectionExpiry(page: SocialPage): string | null { + const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt; + return date ? new Date(date).toISOString() : null; +} + +/** + * Lance l'autorisation TikTok. La connexion se fait par navigation complète du + * navigateur (et non en fetch) puisqu'elle passe par le site de TikTok. + */ +function ConnectTiktokButton() { + const { toast } = useToast(); + + const { data: config } = useQuery<{ configured: boolean }>({ + queryKey: ['/api/tiktok/config'], + }); + + const handleClick = () => { + if (config && !config.configured) { + toast({ + title: "TikTok n'est pas configuré", + description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.", + variant: "destructive", + }); + return; + } + window.location.href = '/api/tiktok/connect'; + }; + + return ( + + ); +} + export default function PagesManagementMobile() { const [sidebarOpen, setSidebarOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [editingPage, setEditingPage] = useState(null); const { toast } = useToast(); + // Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici) + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const result = params.get('tiktok'); + if (!result) return; + + toast({ + title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible', + description: params.get('message') || undefined, + variant: result === 'success' ? undefined : 'destructive', + }); + + queryClient.invalidateQueries({ queryKey: ['/api/pages'] }); + window.history.replaceState({}, '', window.location.pathname); + }, [toast]); + const { data: pages = [], isLoading } = useQuery({ queryKey: ['/api/pages'], }); @@ -119,10 +181,11 @@ export default function PagesManagementMobile() {

Pages gérées

- Connectez et gérez vos pages Facebook et Instagram + Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok

+ !open && setEditingPage(null)} /> @@ -181,6 +244,10 @@ export default function PagesManagementMobile() {
+ ) : page.platform === 'tiktok' ? ( +
+ +
) : (
@@ -202,20 +269,21 @@ export default function PagesManagementMobile() { {/* Token expiration status */} {(() => { - const expirationStatus = getTokenExpirationStatus( - page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null - ); + const expiresAt = getConnectionExpiry(page); + const expirationStatus = getTokenExpirationStatus(expiresAt); return (
-
Expiration du token
+
+ {page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'} +
{expirationStatus.message}
- {page.tokenExpiresAt && ( + {expiresAt && (
- {format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })} + {format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })}
)}
@@ -227,15 +295,27 @@ export default function PagesManagementMobile() { })()}
- + {page.platform === 'tiktok' ? ( + + ) : ( + + )}