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' ? (
+
+ ) : (
+
+ )}