diff --git a/.env.example b/.env.example index cfca62a..a200c81 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,24 @@ 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= + # ========================================== # 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 (
{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/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/pages/mobile/new-post.tsx b/client/src/pages/mobile/new-post.tsx index d463e79..985d2a2 100644 --- a/client/src/pages/mobile/new-post.tsx +++ b/client/src/pages/mobile/new-post.tsx @@ -119,9 +119,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'], 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' ? ( + + ) : ( + + )}