x integration testing.
Browse files- social_media_publishers/app.py +59 -18
- social_media_publishers/factory.py +6 -3
- social_media_publishers/frontend/src/components/GlobalUploadModal.jsx +326 -0
- social_media_publishers/frontend/src/components/TikTokUploadModal.jsx +17 -1
- social_media_publishers/frontend/src/components/UploadModal.jsx +29 -0
- social_media_publishers/frontend/src/pages/Dashboard.jsx +30 -1
- social_media_publishers/oneup_client.py +17 -5
- social_media_publishers/oneup_service.py +33 -10
- social_media_publishers/publisher.py +2 -0
- social_media_publishers/requirements.txt +2 -1
- social_media_publishers/twitter/__init__.py +0 -0
- social_media_publishers/twitter/auth.py +71 -0
- social_media_publishers/twitter/publisher.py +279 -0
- src/config.py +4 -0
social_media_publishers/app.py
CHANGED
|
@@ -132,6 +132,8 @@ async def oauth2callback(request: Request):
|
|
| 132 |
platform = 'tiktok'
|
| 133 |
elif state.startswith('threads_'):
|
| 134 |
platform = 'threads'
|
|
|
|
|
|
|
| 135 |
else:
|
| 136 |
platform = 'youtube' # Default for backward compatibility
|
| 137 |
|
|
@@ -291,11 +293,7 @@ async def publish_video(
|
|
| 291 |
|
| 292 |
|
| 293 |
# 1. Get Publisher
|
| 294 |
-
publisher = PublisherFactory.get_publisher(platform)
|
| 295 |
-
|
| 296 |
# 2. Authenticate
|
| 297 |
-
publisher.authenticate(account_id=account_email)
|
| 298 |
-
|
| 299 |
# 3. Publish
|
| 300 |
is_public = (privacy == 'public')
|
| 301 |
privacy_level = 'PUBLIC_TO_EVERYONE' if is_public else 'SELF_ONLY'
|
|
@@ -330,37 +328,80 @@ async def publish_video(
|
|
| 330 |
# --- ONEUP PROVIDER FLOW ---
|
| 331 |
if provider == 'oneup':
|
| 332 |
from social_media_publishers.oneup_service import OneUpService
|
| 333 |
-
|
| 334 |
try:
|
| 335 |
service = OneUpService()
|
| 336 |
-
# Use account_email as the identifier (it might be username/email/name)
|
| 337 |
-
# If None, it will fail inside service if strictly required, or fallback if designed.
|
| 338 |
result = service.publish_video(
|
| 339 |
platform=platform,
|
| 340 |
content_path=content_path,
|
| 341 |
metadata=metadata,
|
| 342 |
account_identifier=account_email
|
| 343 |
)
|
| 344 |
-
|
| 345 |
if result.get('error'):
|
| 346 |
return JSONResponse(content=result, status_code=400)
|
| 347 |
-
|
| 348 |
return result
|
| 349 |
-
|
| 350 |
except Exception as e:
|
| 351 |
logger.error(f"OneUp Publish Error: {e}", exc_info=True)
|
| 352 |
return JSONResponse(content={'error': f"OneUp Error: {str(e)}"}, status_code=500)
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
-
#
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
|
| 363 |
-
|
|
|
|
| 364 |
|
| 365 |
except Exception as e:
|
| 366 |
logger.error(f"Publish Error: {e}", exc_info=True)
|
|
|
|
| 132 |
platform = 'tiktok'
|
| 133 |
elif state.startswith('threads_'):
|
| 134 |
platform = 'threads'
|
| 135 |
+
elif state.startswith('twitter_'):
|
| 136 |
+
platform = 'twitter'
|
| 137 |
else:
|
| 138 |
platform = 'youtube' # Default for backward compatibility
|
| 139 |
|
|
|
|
| 293 |
|
| 294 |
|
| 295 |
# 1. Get Publisher
|
|
|
|
|
|
|
| 296 |
# 2. Authenticate
|
|
|
|
|
|
|
| 297 |
# 3. Publish
|
| 298 |
is_public = (privacy == 'public')
|
| 299 |
privacy_level = 'PUBLIC_TO_EVERYONE' if is_public else 'SELF_ONLY'
|
|
|
|
| 328 |
# --- ONEUP PROVIDER FLOW ---
|
| 329 |
if provider == 'oneup':
|
| 330 |
from social_media_publishers.oneup_service import OneUpService
|
|
|
|
| 331 |
try:
|
| 332 |
service = OneUpService()
|
|
|
|
|
|
|
| 333 |
result = service.publish_video(
|
| 334 |
platform=platform,
|
| 335 |
content_path=content_path,
|
| 336 |
metadata=metadata,
|
| 337 |
account_identifier=account_email
|
| 338 |
)
|
|
|
|
| 339 |
if result.get('error'):
|
| 340 |
return JSONResponse(content=result, status_code=400)
|
|
|
|
| 341 |
return result
|
|
|
|
| 342 |
except Exception as e:
|
| 343 |
logger.error(f"OneUp Publish Error: {e}", exc_info=True)
|
| 344 |
return JSONResponse(content={'error': f"OneUp Error: {str(e)}"}, status_code=500)
|
| 345 |
|
| 346 |
+
# --- DIRECT API FLOW ---
|
| 347 |
+
# 1. Get Publisher
|
| 348 |
+
logger.info(f"π STEP 1: Getting Publisher for platform {platform}")
|
| 349 |
+
publisher = PublisherFactory.get_publisher(platform)
|
| 350 |
+
if not publisher:
|
| 351 |
+
logger.error(f"β Publisher for {platform} not found")
|
| 352 |
+
return JSONResponse(content={"error": f"Publisher for {platform} not found"}, status_code=404)
|
| 353 |
+
|
| 354 |
+
# 2. Resolve target accounts
|
| 355 |
+
logger.info(f"π STEP 2: Resolving target accounts (Select: {account_email})")
|
| 356 |
+
target_accounts = []
|
| 357 |
+
if account_email == 'all':
|
| 358 |
+
creator = PublisherFactory.get_auth_creator(platform)
|
| 359 |
+
if creator:
|
| 360 |
+
accounts_info = creator.list_connected_accounts()
|
| 361 |
+
for acc in accounts_info:
|
| 362 |
+
# Clean ID extractor
|
| 363 |
+
clean_id = acc['filename'].replace(f"{platform}_token_", '').replace('.json', '')
|
| 364 |
+
target_accounts.append(clean_id)
|
| 365 |
+
|
| 366 |
+
if not target_accounts:
|
| 367 |
+
logger.warning(f"β οΈ No connected accounts found for {platform}")
|
| 368 |
+
return JSONResponse(content={"error": f"No connected accounts found for {platform}"}, status_code=400)
|
| 369 |
+
else:
|
| 370 |
+
target_accounts = [account_email]
|
| 371 |
|
| 372 |
+
# 3. Iterate and Publish
|
| 373 |
+
logger.info(f"π STEP 3: Starting publishing loop for {len(target_accounts)} accounts")
|
| 374 |
+
results = []
|
| 375 |
+
for i, acc_id in enumerate(target_accounts, 1):
|
| 376 |
+
logger.info(f"β³ [{i}/{len(target_accounts)}] Processing account: {acc_id}")
|
| 377 |
+
try:
|
| 378 |
+
# 3a. Authenticate
|
| 379 |
+
logger.info(f" π Authenticating {acc_id}...")
|
| 380 |
+
publisher.authenticate(account_id=acc_id)
|
| 381 |
+
|
| 382 |
+
# 3b. Publish
|
| 383 |
+
logger.info(f" β¬οΈ Publishing to {acc_id} (Title: {title})...")
|
| 384 |
+
res = publisher.publish(content_path, metadata)
|
| 385 |
+
|
| 386 |
+
if res.get("success"):
|
| 387 |
+
logger.info(f" β
Successfully posted to {acc_id}! Post ID: {res.get('post_id') or res.get('media_id')}")
|
| 388 |
+
else:
|
| 389 |
+
logger.error(f" β Failed to post to {acc_id}: {res.get('error')}")
|
| 390 |
+
|
| 391 |
+
results.append({"account": acc_id, "success": res.get("success", False), "result": res})
|
| 392 |
+
except Exception as e:
|
| 393 |
+
logger.error(f" π₯ Fatal error for {acc_id}: {e}", exc_info=True)
|
| 394 |
+
results.append({"account": acc_id, "success": False, "error": str(e)})
|
| 395 |
+
|
| 396 |
+
# Return single result if only one account, else list
|
| 397 |
+
if len(results) == 1:
|
| 398 |
+
final_res = results[0]["result"] if results[0]["success"] else {"success": False, "error": results[0].get("error") or results[0]["result"].get("error")}
|
| 399 |
+
if not results[0]["success"]:
|
| 400 |
+
return JSONResponse(content=final_res, status_code=400)
|
| 401 |
+
return final_res
|
| 402 |
|
| 403 |
+
logger.info(f"π Finished publishing loop. Results: {results}")
|
| 404 |
+
return {"success": True, "multi_results": results}
|
| 405 |
|
| 406 |
except Exception as e:
|
| 407 |
logger.error(f"Publish Error: {e}", exc_info=True)
|
social_media_publishers/factory.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
-
from .youtube.auth import YoutubeAuthCreator
|
| 2 |
-
from .instagram.auth import InstagramAuthCreator
|
| 3 |
-
from .facebook.auth import FacebookAuthCreator
|
| 4 |
from .threads.auth import ThreadsAuthCreator
|
| 5 |
from .tiktok.auth import TikTokAuthCreator
|
|
|
|
| 6 |
|
| 7 |
class PublisherFactory:
|
| 8 |
"""Factory to create social media auth creators and publishers."""
|
|
@@ -20,6 +18,8 @@ class PublisherFactory:
|
|
| 20 |
return TikTokAuthCreator()
|
| 21 |
if platform_lower == 'threads':
|
| 22 |
return ThreadsAuthCreator()
|
|
|
|
|
|
|
| 23 |
raise ValueError(f"Unknown platform: {platform}")
|
| 24 |
|
| 25 |
@staticmethod
|
|
@@ -40,6 +40,9 @@ class PublisherFactory:
|
|
| 40 |
if platform_lower == 'threads':
|
| 41 |
from .threads.publisher import ThreadsPublisher
|
| 42 |
return ThreadsPublisher()
|
|
|
|
|
|
|
|
|
|
| 43 |
raise ValueError(f"Unknown platform: {platform}")
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from .threads.auth import ThreadsAuthCreator
|
| 2 |
from .tiktok.auth import TikTokAuthCreator
|
| 3 |
+
from .twitter.auth import TwitterAuthCreator
|
| 4 |
|
| 5 |
class PublisherFactory:
|
| 6 |
"""Factory to create social media auth creators and publishers."""
|
|
|
|
| 18 |
return TikTokAuthCreator()
|
| 19 |
if platform_lower == 'threads':
|
| 20 |
return ThreadsAuthCreator()
|
| 21 |
+
if platform_lower == 'twitter':
|
| 22 |
+
return TwitterAuthCreator()
|
| 23 |
raise ValueError(f"Unknown platform: {platform}")
|
| 24 |
|
| 25 |
@staticmethod
|
|
|
|
| 40 |
if platform_lower == 'threads':
|
| 41 |
from .threads.publisher import ThreadsPublisher
|
| 42 |
return ThreadsPublisher()
|
| 43 |
+
if platform_lower == 'twitter':
|
| 44 |
+
from .twitter.publisher import TwitterPublisher
|
| 45 |
+
return TwitterPublisher()
|
| 46 |
raise ValueError(f"Unknown platform: {platform}")
|
| 47 |
|
| 48 |
|
social_media_publishers/frontend/src/components/GlobalUploadModal.jsx
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Upload, X, Calendar, Check, Globe, Layout, Smartphone, Share2, AlertCircle, Youtube, Twitter as TwitterIcon, Music2, Instagram, Facebook, AtSign, Loader2 } from 'lucide-react';
|
| 3 |
+
import axios from 'axios';
|
| 4 |
+
|
| 5 |
+
const API_URL = '/social/api';
|
| 6 |
+
|
| 7 |
+
const AVAILABLE_PLATFORMS = [
|
| 8 |
+
{ id: 'youtube', name: 'YouTube', icon: Youtube },
|
| 9 |
+
{ id: 'tiktok', name: 'TikTok', icon: Music2 },
|
| 10 |
+
{ id: 'instagram', name: 'Instagram', icon: Instagram },
|
| 11 |
+
{ id: 'facebook', name: 'Facebook', icon: Facebook },
|
| 12 |
+
{ id: 'threads', name: 'Threads', icon: AtSign },
|
| 13 |
+
{ id: 'twitter', name: 'X (Twitter)', icon: TwitterIcon },
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
+
export default function GlobalUploadModal({ onClose, onSuccess }) {
|
| 17 |
+
const [selectedPlatforms, setSelectedPlatforms] = useState(['youtube']);
|
| 18 |
+
const [provider, setProvider] = useState('direct'); // Global provider for now
|
| 19 |
+
|
| 20 |
+
const [file, setFile] = useState(null);
|
| 21 |
+
const [title, setTitle] = useState('');
|
| 22 |
+
const [description, setDescription] = useState('');
|
| 23 |
+
const [privacy, setPrivacy] = useState('private');
|
| 24 |
+
const [scheduledTime, setScheduledTime] = useState('');
|
| 25 |
+
|
| 26 |
+
const [loading, setLoading] = useState(false);
|
| 27 |
+
const [error, setError] = useState(null);
|
| 28 |
+
const [results, setResults] = useState(null);
|
| 29 |
+
|
| 30 |
+
const [inputType, setInputType] = useState('file'); // 'file' or 'url'
|
| 31 |
+
const [videoUrl, setVideoUrl] = useState('');
|
| 32 |
+
|
| 33 |
+
const togglePlatform = (id) => {
|
| 34 |
+
setSelectedPlatforms(prev =>
|
| 35 |
+
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
|
| 36 |
+
);
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const handleSubmit = async (e) => {
|
| 40 |
+
e.preventDefault();
|
| 41 |
+
|
| 42 |
+
if (selectedPlatforms.length === 0) return setError("Select at least one platform");
|
| 43 |
+
if (inputType === 'file' && !file) return setError("File is required");
|
| 44 |
+
if (inputType === 'url' && !videoUrl) return setError("Video URL is required");
|
| 45 |
+
if (!title) return setError("Title is required");
|
| 46 |
+
|
| 47 |
+
setLoading(true);
|
| 48 |
+
setError(null);
|
| 49 |
+
setResults([]); // Initialize empty results list
|
| 50 |
+
|
| 51 |
+
const uploadResults = [];
|
| 52 |
+
|
| 53 |
+
// Iterate through platforms
|
| 54 |
+
for (const platform of selectedPlatforms) {
|
| 55 |
+
// Update UI to show "Processing" for this specific platform
|
| 56 |
+
setResults(prev => [...prev.filter(r => r.platform !== platform), { platform, status: 'processing', success: false }]);
|
| 57 |
+
|
| 58 |
+
const formData = new FormData();
|
| 59 |
+
formData.append('platform', platform);
|
| 60 |
+
formData.append('provider', provider);
|
| 61 |
+
formData.append('account_email', 'all'); // Post to all accounts
|
| 62 |
+
|
| 63 |
+
if (inputType === 'file') formData.append('file', file);
|
| 64 |
+
else formData.append('video_url', videoUrl);
|
| 65 |
+
|
| 66 |
+
formData.append('title', title);
|
| 67 |
+
formData.append('description', description);
|
| 68 |
+
formData.append('privacy', privacy);
|
| 69 |
+
if (scheduledTime) formData.append('scheduled_time', new Date(scheduledTime).toISOString());
|
| 70 |
+
|
| 71 |
+
try {
|
| 72 |
+
const response = await axios.post(`${API_URL}/publish`, formData, {
|
| 73 |
+
headers: { 'Content-Type': 'multipart/form-data' }
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
const platformResult = {
|
| 77 |
+
platform,
|
| 78 |
+
status: 'completed',
|
| 79 |
+
success: true,
|
| 80 |
+
data: response.data
|
| 81 |
+
};
|
| 82 |
+
uploadResults.push(platformResult);
|
| 83 |
+
setResults(prev => [...prev.filter(r => r.platform !== platform), platformResult]);
|
| 84 |
+
} catch (err) {
|
| 85 |
+
console.error(`Upload to ${platform} failed`, err);
|
| 86 |
+
const platformError = {
|
| 87 |
+
platform,
|
| 88 |
+
status: 'failed',
|
| 89 |
+
success: false,
|
| 90 |
+
error: err.response?.data?.error || "Upload failed"
|
| 91 |
+
};
|
| 92 |
+
uploadResults.push(platformError);
|
| 93 |
+
setResults(prev => [...prev.filter(r => r.platform !== platform), platformError]);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Final summary check
|
| 98 |
+
const allSuccess = uploadResults.every(r => r.success);
|
| 99 |
+
setLoading(false);
|
| 100 |
+
|
| 101 |
+
if (allSuccess) {
|
| 102 |
+
setTimeout(() => {
|
| 103 |
+
onSuccess(uploadResults);
|
| 104 |
+
onClose();
|
| 105 |
+
}, 5000); // 5s to let them see all green
|
| 106 |
+
}
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
return (
|
| 110 |
+
<div className="fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4 transition-all animate-in fade-in duration-300">
|
| 111 |
+
<div className="bg-surface border border-border shadow-[0_0_50px_-12px_rgba(0,0,0,0.5)] rounded-3xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col animate-in zoom-in-95 duration-300">
|
| 112 |
+
|
| 113 |
+
{/* Header */}
|
| 114 |
+
<div className="p-6 border-b border-border flex items-center justify-between bg-white/50 backdrop-blur-sm sticky top-0 z-10">
|
| 115 |
+
<div>
|
| 116 |
+
<h2 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
|
| 117 |
+
<div className="p-2 rounded-xl bg-primary/10 text-primary">
|
| 118 |
+
<Globe className="w-6 h-6" />
|
| 119 |
+
</div>
|
| 120 |
+
Global Publisher
|
| 121 |
+
</h2>
|
| 122 |
+
<p className="text-sm text-secondary mt-1">Scedule & post to all your connected accounts at once.</p>
|
| 123 |
+
</div>
|
| 124 |
+
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full transition-colors">
|
| 125 |
+
<X className="w-6 h-6 text-gray-400" />
|
| 126 |
+
</button>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
|
| 130 |
+
<form onSubmit={handleSubmit} className="grid grid-cols-1 lg:grid-cols-2 gap-10">
|
| 131 |
+
{/* Left Column: Config */}
|
| 132 |
+
<div className="space-y-8">
|
| 133 |
+
{/* Platform Selection */}
|
| 134 |
+
<div className="space-y-4">
|
| 135 |
+
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest flex items-center gap-2">
|
| 136 |
+
<Globe className="w-3 h-3" /> Select Platforms
|
| 137 |
+
</label>
|
| 138 |
+
<div className="grid grid-cols-2 gap-3">
|
| 139 |
+
{AVAILABLE_PLATFORMS.map(p => (
|
| 140 |
+
<button
|
| 141 |
+
key={p.id}
|
| 142 |
+
type="button"
|
| 143 |
+
onClick={() => togglePlatform(p.id)}
|
| 144 |
+
className={`flex items-center gap-3 p-3 rounded-2xl border transition-all ${selectedPlatforms.includes(p.id)
|
| 145 |
+
? 'bg-primary/5 border-primary text-primary shadow-sm'
|
| 146 |
+
: 'bg-white border-border text-slate-600 hover:border-slate-300'
|
| 147 |
+
}`}
|
| 148 |
+
>
|
| 149 |
+
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${selectedPlatforms.includes(p.id) ? 'bg-primary text-white' : 'bg-slate-100 text-slate-500'}`}>
|
| 150 |
+
<p.icon className="w-5 h-5" />
|
| 151 |
+
</div>
|
| 152 |
+
<span className="font-semibold text-sm">{p.name}</span>
|
| 153 |
+
</button>
|
| 154 |
+
))}
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{/* Provider Toggle */}
|
| 159 |
+
<div className="space-y-4">
|
| 160 |
+
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest flex items-center gap-2">
|
| 161 |
+
<Share2 className="w-3 h-3" /> Publishing Flow
|
| 162 |
+
</label>
|
| 163 |
+
<div className="flex p-1.5 bg-slate-100 rounded-2xl border border-border">
|
| 164 |
+
<button
|
| 165 |
+
type="button"
|
| 166 |
+
onClick={() => setProvider('direct')}
|
| 167 |
+
className={`flex-1 py-3 text-sm font-bold rounded-xl transition-all ${provider === 'direct' ? 'bg-white text-slate-900 shadow-md' : 'text-slate-500 hover:text-slate-900'}`}
|
| 168 |
+
>
|
| 169 |
+
Direct API
|
| 170 |
+
</button>
|
| 171 |
+
<button
|
| 172 |
+
type="button"
|
| 173 |
+
onClick={() => setProvider('oneup')}
|
| 174 |
+
className={`flex-1 py-3 text-sm font-bold rounded-xl transition-all ${provider === 'oneup' ? 'bg-white text-slate-900 shadow-md' : 'text-slate-500 hover:text-slate-900'}`}
|
| 175 |
+
>
|
| 176 |
+
OneUp
|
| 177 |
+
</button>
|
| 178 |
+
</div>
|
| 179 |
+
<p className="text-[10px] text-slate-400 px-2 italic">
|
| 180 |
+
{provider === 'direct' ? 'Posts directly via official APIs. Best for immediate results.' : 'Schedules via OneUp. Best for avoiding API tier restrictions.'}
|
| 181 |
+
</p>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
{/* Media Type */}
|
| 185 |
+
<div className="space-y-4">
|
| 186 |
+
<div className="flex bg-slate-100 p-1.5 rounded-2xl border border-border">
|
| 187 |
+
<button type="button" onClick={() => setInputType('file')} className={`flex-1 py-2.5 text-xs font-bold rounded-xl transition-all ${inputType === 'file' ? 'bg-primary text-white shadow-lg' : 'text-slate-500'}`}>File Upload</button>
|
| 188 |
+
<button type="button" onClick={() => setInputType('url')} className={`flex-1 py-2.5 text-xs font-bold rounded-xl transition-all ${inputType === 'url' ? 'bg-primary text-white shadow-lg' : 'text-slate-500'}`}>Direct URL</button>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
{inputType === 'file' ? (
|
| 192 |
+
<div className="border-2 border-dashed border-slate-200 rounded-3xl p-8 text-center hover:border-primary/50 transition-colors bg-slate-50/50 group cursor-pointer relative">
|
| 193 |
+
<div className="w-16 h-16 bg-white rounded-2xl shadow-sm flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
|
| 194 |
+
<Upload className="w-8 h-8 text-primary" />
|
| 195 |
+
</div>
|
| 196 |
+
<p className="text-sm font-bold text-slate-900 mb-1">{file ? file.name : 'Select video or image'}</p>
|
| 197 |
+
<p className="text-xs text-slate-400">{file ? `${(file.size / (1024 * 1024)).toFixed(1)} MB` : 'Drag and drop your file here'}</p>
|
| 198 |
+
<input
|
| 199 |
+
type="file"
|
| 200 |
+
accept="video/*,image/*"
|
| 201 |
+
onChange={(e) => setFile(e.target.files?.[0])}
|
| 202 |
+
className="absolute inset-0 opacity-0 cursor-pointer z-10"
|
| 203 |
+
/>
|
| 204 |
+
</div>
|
| 205 |
+
) : (
|
| 206 |
+
<input
|
| 207 |
+
type="url"
|
| 208 |
+
value={videoUrl}
|
| 209 |
+
onChange={(e) => setVideoUrl(e.target.value)}
|
| 210 |
+
placeholder="https://example.com/video.mp4"
|
| 211 |
+
className="w-full bg-slate-50 border border-slate-200 rounded-2xl px-5 py-4 focus:ring-2 focus:ring-primary/20 outline-none font-medium text-sm"
|
| 212 |
+
/>
|
| 213 |
+
)}
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
{/* Right Column: Metadata */}
|
| 218 |
+
<div className="space-y-6">
|
| 219 |
+
<div className="space-y-2">
|
| 220 |
+
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest pl-1">Title</label>
|
| 221 |
+
<input
|
| 222 |
+
type="text"
|
| 223 |
+
value={title}
|
| 224 |
+
onChange={(e) => setTitle(e.target.value)}
|
| 225 |
+
placeholder="Enter post title..."
|
| 226 |
+
className="w-full bg-white border border-border rounded-2xl px-5 py-4 focus:ring-2 focus:ring-primary/20 outline-none font-bold text-slate-900"
|
| 227 |
+
/>
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
<div className="space-y-2">
|
| 231 |
+
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest pl-1">Description</label>
|
| 232 |
+
<textarea
|
| 233 |
+
value={description}
|
| 234 |
+
onChange={(e) => setDescription(e.target.value)}
|
| 235 |
+
placeholder="Add description and hashtags..."
|
| 236 |
+
className="w-full bg-white border border-border rounded-3xl px-5 py-4 h-48 resize-none focus:ring-2 focus:ring-primary/20 outline-none text-slate-600 leading-relaxed"
|
| 237 |
+
/>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<div className="grid grid-cols-2 gap-4">
|
| 241 |
+
<div className="space-y-2">
|
| 242 |
+
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest pl-1">Privacy</label>
|
| 243 |
+
<select
|
| 244 |
+
value={privacy}
|
| 245 |
+
onChange={(e) => setPrivacy(e.target.value)}
|
| 246 |
+
className="w-full bg-white border border-border rounded-2xl px-5 py-4 focus:ring-2 focus:ring-primary/20 outline-none appearance-none font-semibold text-slate-900"
|
| 247 |
+
>
|
| 248 |
+
<option value="private">Private</option>
|
| 249 |
+
<option value="public">Public</option>
|
| 250 |
+
<option value="unlisted">Unlisted</option>
|
| 251 |
+
</select>
|
| 252 |
+
</div>
|
| 253 |
+
<div className="space-y-2">
|
| 254 |
+
<label className="text-xs font-bold text-slate-500 uppercase tracking-widest pl-1 flex items-center gap-1.5"><Calendar className="w-3 h-3" /> Schedule</label>
|
| 255 |
+
<input
|
| 256 |
+
type="datetime-local"
|
| 257 |
+
value={scheduledTime}
|
| 258 |
+
onChange={(e) => setScheduledTime(e.target.value)}
|
| 259 |
+
className="w-full bg-white border border-border rounded-2xl px-5 py-4 focus:ring-2 focus:ring-primary/20 outline-none text-sm font-semibold text-slate-900"
|
| 260 |
+
/>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
</form>
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
{/* Footer */}
|
| 268 |
+
<div className="p-8 border-t border-border bg-slate-50/50 flex flex-col gap-6">
|
| 269 |
+
{results && results.length > 0 && (
|
| 270 |
+
<div className="space-y-3">
|
| 271 |
+
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-widest pl-1">Processing Status</label>
|
| 272 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
| 273 |
+
{results.map(r => (
|
| 274 |
+
<div key={r.platform} className={`flex items-center justify-between gap-2 px-4 py-3 rounded-2xl border transition-all ${r.status === 'completed' ? 'bg-emerald-50 border-emerald-200 text-emerald-700 shadow-sm' :
|
| 275 |
+
r.status === 'failed' ? 'bg-red-50 border-red-200 text-red-700' :
|
| 276 |
+
'bg-blue-50 border-blue-200 text-blue-700 animate-pulse'
|
| 277 |
+
}`}>
|
| 278 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 279 |
+
{r.status === 'completed' && <div className="p-1 bg-emerald-500 rounded-full text-white"><Check className="w-3 h-3" /></div>}
|
| 280 |
+
{r.status === 'failed' && <div className="p-1 bg-red-500 rounded-full text-white"><X className="w-3 h-3" /></div>}
|
| 281 |
+
{r.status === 'processing' && <Loader2 className="w-4 h-4 animate-spin" />}
|
| 282 |
+
<span className="font-bold text-sm capitalize truncate">{r.platform}</span>
|
| 283 |
+
</div>
|
| 284 |
+
<div className="text-[10px] font-medium opacity-80 italic">
|
| 285 |
+
{r.status === 'completed' ? (r.data?.multi_results ? `Posted to ${r.data.multi_results.length} accounts` : 'Success') :
|
| 286 |
+
r.status === 'failed' ? r.error :
|
| 287 |
+
'Publishing...'}
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
))}
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
)}
|
| 294 |
+
|
| 295 |
+
{error && (
|
| 296 |
+
<div className="p-4 bg-red-500/10 border border-red-500/20 text-red-600 rounded-2xl text-sm font-bold flex items-center gap-3 animate-head-shake">
|
| 297 |
+
<AlertCircle className="w-5 h-5" />
|
| 298 |
+
{error}
|
| 299 |
+
</div>
|
| 300 |
+
)}
|
| 301 |
+
|
| 302 |
+
<div className="flex items-center gap-4">
|
| 303 |
+
<button
|
| 304 |
+
onClick={onClose}
|
| 305 |
+
className="px-8 py-4 rounded-2xl font-bold text-slate-600 hover:bg-slate-200 transition-colors"
|
| 306 |
+
>
|
| 307 |
+
Cancel
|
| 308 |
+
</button>
|
| 309 |
+
<button
|
| 310 |
+
onClick={handleSubmit}
|
| 311 |
+
disabled={loading}
|
| 312 |
+
className="flex-1 bg-primary hover:bg-primary/90 text-white font-bold py-4 rounded-2xl transition-all shadow-xl shadow-primary/20 hover:shadow-primary/30 disabled:opacity-50 flex items-center justify-center gap-3 text-lg"
|
| 313 |
+
>
|
| 314 |
+
{loading ? (
|
| 315 |
+
<div className="w-6 h-6 border-3 border-white/30 border-t-white rounded-full animate-spin" />
|
| 316 |
+
) : (
|
| 317 |
+
<Share2 className="w-6 h-6" />
|
| 318 |
+
)}
|
| 319 |
+
{loading ? 'Processing Queue...' : `Publish to ${selectedPlatforms.length} Platforms`}
|
| 320 |
+
</button>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
);
|
| 326 |
+
}
|
social_media_publishers/frontend/src/components/TikTokUploadModal.jsx
CHANGED
|
@@ -11,6 +11,7 @@ export default function TikTokUploadModal({ platform, onClose, accountEmail, onS
|
|
| 11 |
const [description, setDescription] = useState('');
|
| 12 |
const [inputType, setInputType] = useState('file'); // 'file' or 'url'
|
| 13 |
const [videoUrl, setVideoUrl] = useState('');
|
|
|
|
| 14 |
const [loading, setLoading] = useState(false);
|
| 15 |
const [error, setError] = useState(null);
|
| 16 |
const videoRef = useRef(null);
|
|
@@ -110,6 +111,7 @@ export default function TikTokUploadModal({ platform, onClose, accountEmail, onS
|
|
| 110 |
|
| 111 |
const formData = new FormData();
|
| 112 |
formData.append('platform', platform);
|
|
|
|
| 113 |
if (inputType === 'file') formData.append('file', file);
|
| 114 |
else formData.append('video_url', videoUrl);
|
| 115 |
|
|
@@ -204,8 +206,22 @@ export default function TikTokUploadModal({ platform, onClose, accountEmail, onS
|
|
| 204 |
|
| 205 |
<form onSubmit={handleSubmit} className="flex flex-col lg:flex-row h-full overflow-hidden">
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
{/* LEFT COLUMN: Media Only */}
|
| 208 |
-
<div className="w-full lg:w-1/2 p-5 lg:border-r border-border overflow-y-auto custom-scrollbar flex flex-col justify-center">
|
| 209 |
{/* 1. Media */}
|
| 210 |
<div className="space-y-3 w-full flex flex-col items-center">
|
| 211 |
<div className="flex items-center justify-between w-full max-w-[260px]">
|
|
|
|
| 11 |
const [description, setDescription] = useState('');
|
| 12 |
const [inputType, setInputType] = useState('file'); // 'file' or 'url'
|
| 13 |
const [videoUrl, setVideoUrl] = useState('');
|
| 14 |
+
const [provider, setProvider] = useState('direct'); // 'direct' or 'oneup'
|
| 15 |
const [loading, setLoading] = useState(false);
|
| 16 |
const [error, setError] = useState(null);
|
| 17 |
const videoRef = useRef(null);
|
|
|
|
| 111 |
|
| 112 |
const formData = new FormData();
|
| 113 |
formData.append('platform', platform);
|
| 114 |
+
formData.append('provider', provider);
|
| 115 |
if (inputType === 'file') formData.append('file', file);
|
| 116 |
else formData.append('video_url', videoUrl);
|
| 117 |
|
|
|
|
| 206 |
|
| 207 |
<form onSubmit={handleSubmit} className="flex flex-col lg:flex-row h-full overflow-hidden">
|
| 208 |
|
| 209 |
+
{/* Provider Selection Bar */}
|
| 210 |
+
<div className="lg:absolute lg:top-14 lg:left-0 lg:right-0 bg-slate-100/50 px-5 py-1.5 border-b border-border flex items-center gap-3 shrink-0 z-10">
|
| 211 |
+
<div className="text-[10px] font-semibold text-secondary uppercase tracking-wider">Provider</div>
|
| 212 |
+
<div className="flex bg-white p-0.5 rounded-lg border border-border">
|
| 213 |
+
<button type="button" onClick={() => setProvider('direct')} className={`px-2 py-0.5 text-[10px] font-medium rounded-md transition-all ${provider === 'direct' ? 'bg-primary text-white shadow-sm' : 'text-secondary hover:text-primary'}`}>Direct API</button>
|
| 214 |
+
<button type="button" onClick={() => setProvider('oneup')} className={`px-2 py-0.5 text-[10px] font-medium rounded-md transition-all ${provider === 'oneup' ? 'bg-primary text-white shadow-sm' : 'text-secondary hover:text-primary'}`}>OneUp</button>
|
| 215 |
+
</div>
|
| 216 |
+
{provider === 'oneup' && (
|
| 217 |
+
<div className="text-[9px] text-amber-600 flex items-center gap-1 bg-amber-50 px-2 py-0.5 rounded">
|
| 218 |
+
<Info className="w-2.5 h-2.5" /> Note: Advanced settings (duet/stitch/branded) may be ignored by OneUp.
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
{/* LEFT COLUMN: Media Only */}
|
| 224 |
+
<div className="w-full lg:w-1/2 p-5 lg:pt-12 lg:border-r border-border overflow-y-auto custom-scrollbar flex flex-col justify-center">
|
| 225 |
{/* 1. Media */}
|
| 226 |
<div className="space-y-3 w-full flex flex-col items-center">
|
| 227 |
<div className="flex items-center justify-between w-full max-w-[260px]">
|
social_media_publishers/frontend/src/components/UploadModal.jsx
CHANGED
|
@@ -18,6 +18,7 @@ export default function UploadModal({ platform, onClose, accountEmail, onSuccess
|
|
| 18 |
const [videoUrl, setVideoUrl] = useState('');
|
| 19 |
const [downloadVideo, setDownloadVideo] = useState(false);
|
| 20 |
const [inputType, setInputType] = useState('file'); // 'file' or 'url'
|
|
|
|
| 21 |
|
| 22 |
const handleFileChange = (e) => {
|
| 23 |
if (e.target.files && e.target.files[0]) {
|
|
@@ -47,6 +48,7 @@ export default function UploadModal({ platform, onClose, accountEmail, onSuccess
|
|
| 47 |
|
| 48 |
const formData = new FormData();
|
| 49 |
formData.append('platform', platform);
|
|
|
|
| 50 |
|
| 51 |
if (inputType === 'file') {
|
| 52 |
formData.append('file', file);
|
|
@@ -102,6 +104,33 @@ export default function UploadModal({ platform, onClose, accountEmail, onSuccess
|
|
| 102 |
)}
|
| 103 |
|
| 104 |
<form onSubmit={handleSubmit} className="space-y-5">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
{/* Input Type Toggle */}
|
| 106 |
<div className="flex bg-bg p-1 rounded-xl border border-border">
|
| 107 |
<button
|
|
|
|
| 18 |
const [videoUrl, setVideoUrl] = useState('');
|
| 19 |
const [downloadVideo, setDownloadVideo] = useState(false);
|
| 20 |
const [inputType, setInputType] = useState('file'); // 'file' or 'url'
|
| 21 |
+
const [provider, setProvider] = useState('direct'); // 'direct' or 'oneup'
|
| 22 |
|
| 23 |
const handleFileChange = (e) => {
|
| 24 |
if (e.target.files && e.target.files[0]) {
|
|
|
|
| 48 |
|
| 49 |
const formData = new FormData();
|
| 50 |
formData.append('platform', platform);
|
| 51 |
+
formData.append('provider', provider);
|
| 52 |
|
| 53 |
if (inputType === 'file') {
|
| 54 |
formData.append('file', file);
|
|
|
|
| 104 |
)}
|
| 105 |
|
| 106 |
<form onSubmit={handleSubmit} className="space-y-5">
|
| 107 |
+
{/* Provider Selection */}
|
| 108 |
+
<div className="space-y-2">
|
| 109 |
+
<label className="text-xs font-semibold text-secondary uppercase tracking-wider">Publishing Provider</label>
|
| 110 |
+
<div className="flex bg-bg p-1 rounded-xl border border-border">
|
| 111 |
+
<button
|
| 112 |
+
type="button"
|
| 113 |
+
onClick={() => setProvider('direct')}
|
| 114 |
+
className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-all ${provider === 'direct'
|
| 115 |
+
? 'bg-primary text-white shadow-sm'
|
| 116 |
+
: 'text-secondary hover:text-primary'
|
| 117 |
+
}`}
|
| 118 |
+
>
|
| 119 |
+
Direct API
|
| 120 |
+
</button>
|
| 121 |
+
<button
|
| 122 |
+
type="button"
|
| 123 |
+
onClick={() => setProvider('oneup')}
|
| 124 |
+
className={`flex-1 py-1.5 text-sm font-medium rounded-lg transition-all ${provider === 'oneup'
|
| 125 |
+
? 'bg-primary text-white shadow-sm'
|
| 126 |
+
: 'text-secondary hover:text-primary'
|
| 127 |
+
}`}
|
| 128 |
+
>
|
| 129 |
+
OneUp
|
| 130 |
+
</button>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
{/* Input Type Toggle */}
|
| 135 |
<div className="flex bg-bg p-1 rounded-xl border border-border">
|
| 136 |
<button
|
social_media_publishers/frontend/src/pages/Dashboard.jsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
-
import { Youtube, Music2, Instagram, Facebook, ArrowRight, Download, Loader2, AtSign, ShieldCheck } from 'lucide-react';
|
| 4 |
import { generateDashboardReport } from '../utils/pdfGenerator';
|
| 5 |
import { LoadingButton } from '../components/Skeletons';
|
| 6 |
import ProgressModal from '../components/ProgressModal';
|
|
|
|
|
|
|
| 7 |
|
| 8 |
const platforms = [
|
| 9 |
{
|
|
@@ -55,6 +57,16 @@ const platforms = [
|
|
| 55 |
borderColor: 'group-hover:border-slate-800/50',
|
| 56 |
bgHover: 'group-hover:shadow-[0_0_40px_-5px_rgba(30,41,59,0.3)]',
|
| 57 |
status: 'Active'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
];
|
| 60 |
|
|
@@ -64,6 +76,7 @@ export default function Dashboard() {
|
|
| 64 |
const [progressLogs, setProgressLogs] = useState([]);
|
| 65 |
const [isComplete, setIsComplete] = useState(false);
|
| 66 |
const [pdfResult, setPdfResult] = useState(null);
|
|
|
|
| 67 |
|
| 68 |
const handleDownloadReport = async () => {
|
| 69 |
try {
|
|
@@ -130,6 +143,14 @@ export default function Dashboard() {
|
|
| 130 |
|
| 131 |
return (
|
| 132 |
<div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
<ProgressModal
|
| 134 |
isOpen={showProgress}
|
| 135 |
title={isComplete ? "Report Generated!" : "Generating Report..."}
|
|
@@ -193,6 +214,14 @@ export default function Dashboard() {
|
|
| 193 |
</div>
|
| 194 |
|
| 195 |
<div className="flex gap-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
<LoadingButton
|
| 197 |
onClick={handleVerifyAppReview}
|
| 198 |
loading={verifying}
|
|
|
|
| 1 |
import React, { useState } from 'react';
|
| 2 |
import { Link } from 'react-router-dom';
|
| 3 |
+
import { Youtube, Music2, Instagram, Facebook, ArrowRight, Download, Loader2, AtSign, ShieldCheck, Twitter } from 'lucide-react';
|
| 4 |
import { generateDashboardReport } from '../utils/pdfGenerator';
|
| 5 |
import { LoadingButton } from '../components/Skeletons';
|
| 6 |
import ProgressModal from '../components/ProgressModal';
|
| 7 |
+
import GlobalUploadModal from '../components/GlobalUploadModal';
|
| 8 |
+
import { Upload } from 'lucide-react';
|
| 9 |
|
| 10 |
const platforms = [
|
| 11 |
{
|
|
|
|
| 57 |
borderColor: 'group-hover:border-slate-800/50',
|
| 58 |
bgHover: 'group-hover:shadow-[0_0_40px_-5px_rgba(30,41,59,0.3)]',
|
| 59 |
status: 'Active'
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
id: 'twitter',
|
| 63 |
+
name: 'X (Twitter)',
|
| 64 |
+
description: 'Publish tweets, images & videos',
|
| 65 |
+
icon: Twitter,
|
| 66 |
+
color: 'text-sky-500',
|
| 67 |
+
borderColor: 'group-hover:border-sky-500/50',
|
| 68 |
+
bgHover: 'group-hover:shadow-[0_0_40px_-5px_rgba(14,165,233,0.3)]',
|
| 69 |
+
status: 'Active'
|
| 70 |
}
|
| 71 |
];
|
| 72 |
|
|
|
|
| 76 |
const [progressLogs, setProgressLogs] = useState([]);
|
| 77 |
const [isComplete, setIsComplete] = useState(false);
|
| 78 |
const [pdfResult, setPdfResult] = useState(null);
|
| 79 |
+
const [showGlobalUpload, setShowGlobalUpload] = useState(false);
|
| 80 |
|
| 81 |
const handleDownloadReport = async () => {
|
| 82 |
try {
|
|
|
|
| 143 |
|
| 144 |
return (
|
| 145 |
<div>
|
| 146 |
+
{showGlobalUpload && (
|
| 147 |
+
<GlobalUploadModal
|
| 148 |
+
onClose={() => setShowGlobalUpload(false)}
|
| 149 |
+
onSuccess={() => {
|
| 150 |
+
// success handled in modal for now
|
| 151 |
+
}}
|
| 152 |
+
/>
|
| 153 |
+
)}
|
| 154 |
<ProgressModal
|
| 155 |
isOpen={showProgress}
|
| 156 |
title={isComplete ? "Report Generated!" : "Generating Report..."}
|
|
|
|
| 214 |
</div>
|
| 215 |
|
| 216 |
<div className="flex gap-3">
|
| 217 |
+
<LoadingButton
|
| 218 |
+
onClick={() => setShowGlobalUpload(true)}
|
| 219 |
+
className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-xl font-medium transition-all shadow-lg shadow-primary/20"
|
| 220 |
+
>
|
| 221 |
+
<Upload className="w-4 h-4" />
|
| 222 |
+
Global Upload
|
| 223 |
+
</LoadingButton>
|
| 224 |
+
|
| 225 |
<LoadingButton
|
| 226 |
onClick={handleVerifyAppReview}
|
| 227 |
loading={verifying}
|
social_media_publishers/oneup_client.py
CHANGED
|
@@ -137,14 +137,26 @@ class OneUpClient:
|
|
| 137 |
accounts = self.get_all_accounts()
|
| 138 |
platform_type = platform_type.lower()
|
| 139 |
|
| 140 |
-
# Normalize platform names from OneUp to internal
|
| 141 |
-
# OneUp types: Facebook, X, Instagram, GBP, LinkedIn, TikTok, Threads, YouTube
|
| 142 |
-
|
| 143 |
for acc in accounts:
|
| 144 |
net_type = acc.get('social_network_type', '').lower()
|
| 145 |
|
| 146 |
-
#
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
continue
|
| 149 |
|
| 150 |
# Check identifier
|
|
|
|
| 137 |
accounts = self.get_all_accounts()
|
| 138 |
platform_type = platform_type.lower()
|
| 139 |
|
|
|
|
|
|
|
|
|
|
| 140 |
for acc in accounts:
|
| 141 |
net_type = acc.get('social_network_type', '').lower()
|
| 142 |
|
| 143 |
+
# Normalize platform names from OneUp to internal
|
| 144 |
+
# OneUp types: Facebook, X, Instagram, GBP, LinkedIn, TikTok, Threads, YouTube
|
| 145 |
+
# Internal types: facebook, twitter, instagram, tiktok, youtube, threads
|
| 146 |
+
|
| 147 |
+
# Platform name mapping
|
| 148 |
+
mapping = {
|
| 149 |
+
"twitter": "x",
|
| 150 |
+
"x": "twitter"
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
match = False
|
| 154 |
+
if platform_type in net_type or net_type in platform_type:
|
| 155 |
+
match = True
|
| 156 |
+
elif mapping.get(platform_type) == net_type:
|
| 157 |
+
match = True
|
| 158 |
+
|
| 159 |
+
if not match:
|
| 160 |
continue
|
| 161 |
|
| 162 |
# Check identifier
|
social_media_publishers/oneup_service.py
CHANGED
|
@@ -49,22 +49,39 @@ class OneUpService:
|
|
| 49 |
target_social_network_ids = []
|
| 50 |
category_id = None
|
| 51 |
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
account = self.client.find_account(platform, account_identifier)
|
| 54 |
if account:
|
| 55 |
logger.info(f"OneUpService: Found account {account.get('social_network_name')} (ID: {account.get('social_network_id')})")
|
| 56 |
target_social_network_ids.append(account.get('social_network_id'))
|
| 57 |
-
# If the account has a category link (simulated in my client helper), use it.
|
| 58 |
-
# However, the API *list_social_accounts* doesn't strictly return category_id.
|
| 59 |
-
# The *list_category_accounts* does.
|
| 60 |
-
# My client helper tries to inject it.
|
| 61 |
category_id = account.get('category_id')
|
| 62 |
else:
|
| 63 |
logger.warning(f"OneUpService: Could not find specific account for '{account_identifier}'.")
|
| 64 |
-
# Fallback? Maybe publish to ALL in a default category?
|
| 65 |
-
# For now, let's fail if specific account requested but not found,
|
| 66 |
-
# OR if user wanted specific account but we are unsure.
|
| 67 |
-
# But if we can't find it, we can't get its ID.
|
| 68 |
return {"error": f"OneUp account not found for {platform} user {account_identifier}"}
|
| 69 |
|
| 70 |
# If we didn't get a category_id from the account lookup (e.g. if we used list_social_accounts and it didn't have it),
|
|
@@ -78,7 +95,9 @@ class OneUpService:
|
|
| 78 |
|
| 79 |
# Just pick the first one for now
|
| 80 |
category_id = categories[0]['id']
|
| 81 |
-
logger.info(f"OneUpService: Using fallback category {categories[0]['category_name']} ({category_id})")
|
|
|
|
|
|
|
| 82 |
|
| 83 |
# If no specific account found/requested, are we broadcasting?
|
| 84 |
# The logic in app.py implies one account focus.
|
|
@@ -112,8 +131,11 @@ class OneUpService:
|
|
| 112 |
return {"error": f"Failed to upload video for OneUp: {e}"}
|
| 113 |
|
| 114 |
if not video_url:
|
|
|
|
| 115 |
return {"error": "No video URL available for OneUp publishing."}
|
| 116 |
|
|
|
|
|
|
|
| 117 |
# 3. Publish
|
| 118 |
try:
|
| 119 |
logger.info(f"OneUpService: Scheduling post to category {category_id}, accounts {target_social_network_ids}")
|
|
@@ -129,6 +151,7 @@ class OneUpService:
|
|
| 129 |
if result.get('error'):
|
| 130 |
return {"error": result.get('message')}
|
| 131 |
|
|
|
|
| 132 |
return {
|
| 133 |
"success": True,
|
| 134 |
"message": result.get("message"),
|
|
|
|
| 49 |
target_social_network_ids = []
|
| 50 |
category_id = None
|
| 51 |
|
| 52 |
+
logger.info(f"π STEP 1: Resolving OneUp account for {platform} (Identifier: {account_identifier})")
|
| 53 |
+
if account_identifier == 'all':
|
| 54 |
+
logger.info(f"OneUpService: Resolving ALL accounts for platform {platform}")
|
| 55 |
+
accounts = self.client.get_all_accounts()
|
| 56 |
+
# Handle platform mapping consistency
|
| 57 |
+
mapping = {"twitter": "x", "x": "twitter"}
|
| 58 |
+
p_lower = platform.lower()
|
| 59 |
+
|
| 60 |
+
for acc in accounts:
|
| 61 |
+
net_type = acc.get('social_network_type', '').lower()
|
| 62 |
+
match = False
|
| 63 |
+
if p_lower in net_type or net_type in p_lower:
|
| 64 |
+
match = True
|
| 65 |
+
elif mapping.get(p_lower) == net_type:
|
| 66 |
+
match = True
|
| 67 |
+
|
| 68 |
+
if match:
|
| 69 |
+
logger.info(f"OneUpService: Adding account {acc.get('social_network_name')} (ID: {acc.get('social_network_id')})")
|
| 70 |
+
target_social_network_ids.append(acc.get('social_network_id'))
|
| 71 |
+
if not category_id:
|
| 72 |
+
category_id = acc.get('category_id')
|
| 73 |
+
|
| 74 |
+
if not target_social_network_ids:
|
| 75 |
+
return {"error": f"No OneUp accounts found for platform {platform}"}
|
| 76 |
+
|
| 77 |
+
elif account_identifier:
|
| 78 |
account = self.client.find_account(platform, account_identifier)
|
| 79 |
if account:
|
| 80 |
logger.info(f"OneUpService: Found account {account.get('social_network_name')} (ID: {account.get('social_network_id')})")
|
| 81 |
target_social_network_ids.append(account.get('social_network_id'))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
category_id = account.get('category_id')
|
| 83 |
else:
|
| 84 |
logger.warning(f"OneUpService: Could not find specific account for '{account_identifier}'.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
return {"error": f"OneUp account not found for {platform} user {account_identifier}"}
|
| 86 |
|
| 87 |
# If we didn't get a category_id from the account lookup (e.g. if we used list_social_accounts and it didn't have it),
|
|
|
|
| 95 |
|
| 96 |
# Just pick the first one for now
|
| 97 |
category_id = categories[0]['id']
|
| 98 |
+
logger.info(f"β
OneUpService: Using fallback category {categories[0]['category_name']} ({category_id})")
|
| 99 |
+
|
| 100 |
+
logger.info(f"π STEP 2: Preparing media for OneUp (Path: {content_path})")
|
| 101 |
|
| 102 |
# If no specific account found/requested, are we broadcasting?
|
| 103 |
# The logic in app.py implies one account focus.
|
|
|
|
| 131 |
return {"error": f"Failed to upload video for OneUp: {e}"}
|
| 132 |
|
| 133 |
if not video_url:
|
| 134 |
+
logger.error("β OneUpService: No video URL available after media prep.")
|
| 135 |
return {"error": "No video URL available for OneUp publishing."}
|
| 136 |
|
| 137 |
+
logger.info(f"π STEP 3: Scheduling post to OneUp (Category: {category_id}, Accounts: {target_social_network_ids})")
|
| 138 |
+
|
| 139 |
# 3. Publish
|
| 140 |
try:
|
| 141 |
logger.info(f"OneUpService: Scheduling post to category {category_id}, accounts {target_social_network_ids}")
|
|
|
|
| 151 |
if result.get('error'):
|
| 152 |
return {"error": result.get('message')}
|
| 153 |
|
| 154 |
+
logger.info(f"π OneUpService: Successfully scheduled post! Result: {result.get('message')}")
|
| 155 |
return {
|
| 156 |
"success": True,
|
| 157 |
"message": result.get("message"),
|
social_media_publishers/publisher.py
CHANGED
|
@@ -323,6 +323,8 @@ def run_official_publisher():
|
|
| 323 |
account_id = get_config_value("INSTAGRAM_USERNAME")
|
| 324 |
elif platform == 'facebook':
|
| 325 |
account_id = get_config_value("FACEBOOK_USERNAME")
|
|
|
|
|
|
|
| 326 |
|
| 327 |
if not account_id:
|
| 328 |
logger.warning(f"β οΈ No account configured for {platform} (e.g. {platform.upper()}_USERNAME/EMAIL). Using default auth.")
|
|
|
|
| 323 |
account_id = get_config_value("INSTAGRAM_USERNAME")
|
| 324 |
elif platform == 'facebook':
|
| 325 |
account_id = get_config_value("FACEBOOK_USERNAME")
|
| 326 |
+
elif platform == 'twitter':
|
| 327 |
+
account_id = get_config_value("X_USERNAME")
|
| 328 |
|
| 329 |
if not account_id:
|
| 330 |
logger.warning(f"β οΈ No account configured for {platform} (e.g. {platform.upper()}_USERNAME/EMAIL). Using default auth.")
|
social_media_publishers/requirements.txt
CHANGED
|
@@ -25,4 +25,5 @@ cryptography
|
|
| 25 |
git+https://github.com/jebin2/youtube_auto_pub.git
|
| 26 |
aiohttp
|
| 27 |
aiofiles
|
| 28 |
-
itsdangerous
|
|
|
|
|
|
| 25 |
git+https://github.com/jebin2/youtube_auto_pub.git
|
| 26 |
aiohttp
|
| 27 |
aiofiles
|
| 28 |
+
itsdangerous
|
| 29 |
+
xdk
|
social_media_publishers/twitter/__init__.py
ADDED
|
File without changes
|
social_media_publishers/twitter/auth.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Dict, List, Optional, Any
|
| 3 |
+
from xdk.oauth2_auth import OAuth2PKCEAuth
|
| 4 |
+
from ..base import SocialAuthCreator
|
| 5 |
+
from src.config import get_config_value
|
| 6 |
+
|
| 7 |
+
class TwitterAuthCreator(SocialAuthCreator):
|
| 8 |
+
"""Auth creator for X (Twitter) using xdk."""
|
| 9 |
+
|
| 10 |
+
def __init__(self):
|
| 11 |
+
super().__init__(platform_name="twitter")
|
| 12 |
+
self.scopes = [
|
| 13 |
+
"tweet.read",
|
| 14 |
+
"tweet.write",
|
| 15 |
+
"users.read",
|
| 16 |
+
"offline.access",
|
| 17 |
+
"media.write"
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
def _get_auth_client(self, redirect_uri: str) -> OAuth2PKCEAuth:
|
| 21 |
+
client_id = get_config_value("x_client_id")
|
| 22 |
+
client_secret = get_config_value("x_client_secret")
|
| 23 |
+
|
| 24 |
+
if not client_id:
|
| 25 |
+
raise ValueError("x_client_id not found in configuration")
|
| 26 |
+
|
| 27 |
+
return OAuth2PKCEAuth(
|
| 28 |
+
client_id=client_id,
|
| 29 |
+
client_secret=client_secret,
|
| 30 |
+
redirect_uri=redirect_uri,
|
| 31 |
+
scope=self.scopes
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def get_auth_url(self, redirect_uri: str) -> Dict[str, Any]:
|
| 35 |
+
"""Generate X OAuth 2.0 auth URL with PKCE."""
|
| 36 |
+
auth = self._get_auth_client(redirect_uri)
|
| 37 |
+
url = auth.get_authorization_url()
|
| 38 |
+
|
| 39 |
+
return {
|
| 40 |
+
"url": url,
|
| 41 |
+
"state": "twitter_auth", # xdk manages state internally if desired, or we can pass it
|
| 42 |
+
"code_verifier": auth.get_code_verifier()
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
def handle_callback(self, request_url: str, state: str, redirect_uri: str, **kwargs) -> Dict[str, Any]:
|
| 46 |
+
"""Handle the OAuth 2.0 callback."""
|
| 47 |
+
code_verifier = kwargs.get("code_verifier")
|
| 48 |
+
if not code_verifier:
|
| 49 |
+
return {"success": False, "error": "Missing code_verifier"}
|
| 50 |
+
|
| 51 |
+
auth = self._get_auth_client(redirect_uri)
|
| 52 |
+
auth.set_pkce_parameters(code_verifier=code_verifier)
|
| 53 |
+
|
| 54 |
+
token = auth.fetch_token(request_url)
|
| 55 |
+
|
| 56 |
+
if token:
|
| 57 |
+
# We might want to get the username/id to save it in a identifiable way
|
| 58 |
+
# But SocialAuthCreator's save_token_data usually expects a name
|
| 59 |
+
# For now, let's just save it.
|
| 60 |
+
# We can use get_me to find the username
|
| 61 |
+
from xdk import Client
|
| 62 |
+
client = Client(token=token)
|
| 63 |
+
me_resp = client.users.get_me()
|
| 64 |
+
username = getattr(me_resp.data, "username", "twitter_user") if me_resp.data else "twitter_user"
|
| 65 |
+
|
| 66 |
+
filename = f"{self.token_prefix}{username.replace('@', '_at_')}.json"
|
| 67 |
+
self.save_token_data(token, filename)
|
| 68 |
+
|
| 69 |
+
return {"success": True, "account": username}
|
| 70 |
+
|
| 71 |
+
return {"success": False, "error": "Failed to fetch token"}
|
social_media_publishers/twitter/publisher.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import glob
|
| 4 |
+
from typing import Dict, List, Optional, Any
|
| 5 |
+
from xdk import Client
|
| 6 |
+
from xdk.oauth2_auth import OAuth2PKCEAuth
|
| 7 |
+
from xdk.posts.models import CreateRequest, CreateRequestMedia
|
| 8 |
+
from xdk.media.models import UploadRequest, InitializeUploadRequest, AppendUploadRequest
|
| 9 |
+
from ..base import SocialPublisher, SocialAuthCreator
|
| 10 |
+
from src.config import get_config_value
|
| 11 |
+
|
| 12 |
+
class TwitterPublisher(SocialPublisher):
|
| 13 |
+
"""Publisher for X (Twitter) using xdk."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
super().__init__()
|
| 17 |
+
self.platform_name = "twitter"
|
| 18 |
+
self.token_prefix = "twitter_token_"
|
| 19 |
+
|
| 20 |
+
def _get_token_manager(self):
|
| 21 |
+
# We can reuse the one from SocialAuthCreator if we want,
|
| 22 |
+
# but SocialPublisher doesn't inherit from SocialAuthCreator.
|
| 23 |
+
# We need a way to get the token.
|
| 24 |
+
# Standard pattern in this codebase seems to be checking 'encrypt' folder.
|
| 25 |
+
from .auth import TwitterAuthCreator
|
| 26 |
+
return TwitterAuthCreator()._get_token_manager()
|
| 27 |
+
|
| 28 |
+
def authenticate(self, account_id: Optional[str] = None) -> Optional[Client]:
|
| 29 |
+
"""Authenticate with X using stored tokens."""
|
| 30 |
+
tm = self._get_token_manager()
|
| 31 |
+
if not tm:
|
| 32 |
+
print("β TokenManager not available")
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
# If account_id is not provided, try to find the first connected account
|
| 36 |
+
if not account_id:
|
| 37 |
+
from .auth import TwitterAuthCreator
|
| 38 |
+
accounts = TwitterAuthCreator().list_connected_accounts()
|
| 39 |
+
if not accounts:
|
| 40 |
+
print("β No connected X accounts found")
|
| 41 |
+
return None
|
| 42 |
+
account_id = accounts[0]['name']
|
| 43 |
+
|
| 44 |
+
# Construct filename
|
| 45 |
+
filename = f"{self.token_prefix}{account_id.replace('@', '_at_')}.json"
|
| 46 |
+
|
| 47 |
+
# Ensure token is downloaded and decrypted
|
| 48 |
+
try:
|
| 49 |
+
tm.download_and_decrypt(filename)
|
| 50 |
+
except Exception as e:
|
| 51 |
+
print(f"β οΈ Failed to sync token from HF: {e}")
|
| 52 |
+
|
| 53 |
+
encrypt_path = "encrypt"
|
| 54 |
+
if hasattr(tm, 'config') and hasattr(tm.config, 'encrypt_path'):
|
| 55 |
+
encrypt_path = tm.config.encrypt_path
|
| 56 |
+
|
| 57 |
+
token_path = os.path.join(encrypt_path, filename)
|
| 58 |
+
if not os.path.exists(token_path):
|
| 59 |
+
print(f"β Token file not found: {token_path}")
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
with open(token_path, 'r') as f:
|
| 63 |
+
token = json.load(f)
|
| 64 |
+
|
| 65 |
+
client_id = get_config_value("x_client_id")
|
| 66 |
+
client_secret = get_config_value("x_client_secret")
|
| 67 |
+
|
| 68 |
+
# Initialize xdk Client with token and OAuth2PKCEAuth for refresh support
|
| 69 |
+
auth = OAuth2PKCEAuth(
|
| 70 |
+
client_id=client_id,
|
| 71 |
+
client_secret=client_secret,
|
| 72 |
+
token=token,
|
| 73 |
+
redirect_uri="https://localhost/callback" # Dummy, matching what was used in auth
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
return Client(token=token, client_id=client_id, client_secret=client_secret)
|
| 77 |
+
|
| 78 |
+
def publish(self, content_path: str, metadata: Dict[str, Any]) -> Dict[str, Any]:
|
| 79 |
+
"""Publish a tweet with optional media."""
|
| 80 |
+
client = self.authenticate(metadata.get('account_id'))
|
| 81 |
+
if not client:
|
| 82 |
+
return {"success": False, "error": "Authentication failed"}
|
| 83 |
+
|
| 84 |
+
text = metadata.get('text') or metadata.get('title') or metadata.get('caption', "")
|
| 85 |
+
|
| 86 |
+
# Prepare media if path provided
|
| 87 |
+
media_id = None
|
| 88 |
+
if content_path:
|
| 89 |
+
local_path = self.prepare_content(content_path, metadata)
|
| 90 |
+
if local_path and os.path.exists(local_path):
|
| 91 |
+
print(f"π€ Uploading media to X: {local_path}")
|
| 92 |
+
try:
|
| 93 |
+
# In xdk, upload seems to expect media as a field.
|
| 94 |
+
# If it's a file path, we might need to read it if the SDK doesn't.
|
| 95 |
+
# Based on my research into xdk/media/client.py, it uses requests with json=...
|
| 96 |
+
# This suggests media might need to be base64 or a URL, or the SDK is incomplete for files.
|
| 97 |
+
# HOWEVER, many X libraries use chunked upload for videos.
|
| 98 |
+
|
| 99 |
+
# Let's try a simple upload first if it's an image.
|
| 100 |
+
# For videos, we might need chunked.
|
| 101 |
+
is_video = local_path.lower().endswith(('.mp4', '.mov', '.avi'))
|
| 102 |
+
category = "TWEET_VIDEO" if is_video else "TWEET_IMAGE"
|
| 103 |
+
|
| 104 |
+
if is_video:
|
| 105 |
+
media_id = self._upload_media_chunked(client, local_path, category)
|
| 106 |
+
else:
|
| 107 |
+
# For images, simple upload might work, but chunked is safer for large ones too
|
| 108 |
+
import base64
|
| 109 |
+
with open(local_path, "rb") as image_file:
|
| 110 |
+
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
| 111 |
+
|
| 112 |
+
upload_req = UploadRequest(media=encoded_string, media_category=category.lower())
|
| 113 |
+
upload_res = client.media.upload(body=upload_req)
|
| 114 |
+
|
| 115 |
+
if upload_res and upload_res.data:
|
| 116 |
+
media_id = upload_res.data.id
|
| 117 |
+
print(f"β
Image uploaded successfully, ID: {media_id}")
|
| 118 |
+
except Exception as e:
|
| 119 |
+
print(f"β Error uploading media: {e}")
|
| 120 |
+
# If media upload fails, we might still try to post text if it's allowed
|
| 121 |
+
# but usually better to report failure if media was requested.
|
| 122 |
+
|
| 123 |
+
# Create Tweet
|
| 124 |
+
try:
|
| 125 |
+
media_info = None
|
| 126 |
+
if media_id:
|
| 127 |
+
media_info = CreateRequestMedia(media_ids=[media_id])
|
| 128 |
+
|
| 129 |
+
create_req = CreateRequest(text=text, media=media_info)
|
| 130 |
+
response = client.posts.create(body=create_req)
|
| 131 |
+
|
| 132 |
+
if response and response.data:
|
| 133 |
+
tweet_id = response.data.id
|
| 134 |
+
print(f"π Tweet published! ID: {tweet_id}")
|
| 135 |
+
return {
|
| 136 |
+
"success": True,
|
| 137 |
+
"platform": "twitter",
|
| 138 |
+
"post_id": tweet_id,
|
| 139 |
+
"url": f"https://x.com/i/status/{tweet_id}"
|
| 140 |
+
}
|
| 141 |
+
else:
|
| 142 |
+
return {"success": False, "error": f"Failed to create tweet: {response}"}
|
| 143 |
+
except Exception as e:
|
| 144 |
+
msg = str(e)
|
| 145 |
+
if "402" in msg:
|
| 146 |
+
print("β X API Error: Payment Required (402).")
|
| 147 |
+
print("π‘ This usually means your X Developer App is on the 'Free' tier, which has very low limits (500 posts/month) or restricts media-posts.")
|
| 148 |
+
print("π‘ Check your Developer Portal: https://developer.x.com/en/portal/dashboard")
|
| 149 |
+
return {"success": False, "error": "X API Payment Required (Check Tier)"}
|
| 150 |
+
|
| 151 |
+
print(f"β Error creating tweet: {e}")
|
| 152 |
+
return {"success": False, "error": str(e)}
|
| 153 |
+
|
| 154 |
+
def _upload_media_chunked(self, client: Client, file_path: str, media_category: str) -> Optional[str]:
|
| 155 |
+
"""Upload media in chunks (for videos)."""
|
| 156 |
+
import base64
|
| 157 |
+
import time
|
| 158 |
+
from xdk.media.models import InitializeUploadRequest, AppendUploadRequest
|
| 159 |
+
|
| 160 |
+
file_size = os.path.getsize(file_path)
|
| 161 |
+
# category should be UPPERCASE like TWEET_VIDEO per X docs
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
# 1. INIT
|
| 165 |
+
print(f"π¬ Initializing chunked upload for {file_path} ({file_size} bytes)")
|
| 166 |
+
init_req = InitializeUploadRequest(
|
| 167 |
+
media_category=media_category,
|
| 168 |
+
media_type="video/mp4",
|
| 169 |
+
total_bytes=file_size
|
| 170 |
+
)
|
| 171 |
+
init_res = client.media.initialize_upload(body=init_req)
|
| 172 |
+
media_id = init_res.data.id
|
| 173 |
+
if not media_id:
|
| 174 |
+
print("β Failed to get media_id during INIT")
|
| 175 |
+
return None
|
| 176 |
+
|
| 177 |
+
# 2. APPEND
|
| 178 |
+
segment_index = 0
|
| 179 |
+
chunk_size = 1024 * 1024 # 1MB chunks
|
| 180 |
+
with open(file_path, 'rb') as f:
|
| 181 |
+
while True:
|
| 182 |
+
chunk = f.read(chunk_size)
|
| 183 |
+
if not chunk:
|
| 184 |
+
break
|
| 185 |
+
|
| 186 |
+
encoded_chunk = base64.b64encode(chunk).decode('utf-8')
|
| 187 |
+
append_req = AppendUploadRequest(
|
| 188 |
+
media=encoded_chunk,
|
| 189 |
+
segment_index=segment_index
|
| 190 |
+
)
|
| 191 |
+
client.media.append_upload(id=media_id, body=append_req)
|
| 192 |
+
segment_index += 1
|
| 193 |
+
|
| 194 |
+
print(f"π€ Uploaded {segment_index} chunks.")
|
| 195 |
+
|
| 196 |
+
# 3. FINALIZE
|
| 197 |
+
client.media.finalize_upload(id=media_id)
|
| 198 |
+
|
| 199 |
+
# 4. STATUS CHECK (wait for processing)
|
| 200 |
+
print(f"β Waiting for video processing for ID: {media_id}")
|
| 201 |
+
for i in range(12): # Wait up to 1 minute
|
| 202 |
+
status = client.media.get_upload_status(media_id=media_id)
|
| 203 |
+
# Correctly handle model attributes
|
| 204 |
+
if status and status.data and status.data.processing_info:
|
| 205 |
+
info = status.data.processing_info
|
| 206 |
+
state = getattr(info, "state", "pending")
|
| 207 |
+
if state == "succeeded":
|
| 208 |
+
print(f"β
Video processing complete!")
|
| 209 |
+
return media_id
|
| 210 |
+
if state == "failed":
|
| 211 |
+
print(f"β Video processing failed: {info}")
|
| 212 |
+
return None
|
| 213 |
+
|
| 214 |
+
check_after = getattr(info, "check_after_secs", 5) or 5
|
| 215 |
+
print(f"...processing ({state}, {getattr(info, 'progress_percent', 0)}%). Waiting {check_after}s...")
|
| 216 |
+
time.sleep(check_after)
|
| 217 |
+
else:
|
| 218 |
+
# If no processing info yet, just wait a bit
|
| 219 |
+
print(f"...waiting for status...")
|
| 220 |
+
time.sleep(5)
|
| 221 |
+
|
| 222 |
+
return media_id
|
| 223 |
+
except Exception as e:
|
| 224 |
+
print(f"β Error in chunked upload: {e}")
|
| 225 |
+
return None
|
| 226 |
+
|
| 227 |
+
def get_uploaded_videos(self, account_id: str, limit: int = 10, page_token: str = None, start_date: str = None, end_date: str = None) -> Dict[str, Any]:
|
| 228 |
+
"""Get recent tweets (optionally filtered)."""
|
| 229 |
+
client = self.authenticate(account_id)
|
| 230 |
+
if not client:
|
| 231 |
+
return {"videos": []}
|
| 232 |
+
|
| 233 |
+
try:
|
| 234 |
+
# Get user ID
|
| 235 |
+
me_resp = client.users.get_me()
|
| 236 |
+
user_id = me_resp.data.id if me_resp and me_resp.data else None
|
| 237 |
+
if not user_id:
|
| 238 |
+
return {"videos": []}
|
| 239 |
+
|
| 240 |
+
# Get tweets
|
| 241 |
+
# xdk often has helper methods or we use client.users.get_posts
|
| 242 |
+
response = client.users.get_posts(id=user_id, max_results=limit, pagination_token=page_token)
|
| 243 |
+
tweets = response.data or []
|
| 244 |
+
|
| 245 |
+
return {
|
| 246 |
+
"videos": [
|
| 247 |
+
{
|
| 248 |
+
"id": getattr(t, "id", None),
|
| 249 |
+
"title": (getattr(t, "text", "")[:50] + "...") if getattr(t, "text", None) else "No text",
|
| 250 |
+
"published_at": getattr(t, "created_at", None),
|
| 251 |
+
"views": 0, # X API v2 requires specific fields for public metrics
|
| 252 |
+
"thumbnail": ""
|
| 253 |
+
} for t in tweets
|
| 254 |
+
],
|
| 255 |
+
"next_page_token": response.meta.next_token if response and response.meta else None
|
| 256 |
+
}
|
| 257 |
+
except Exception as e:
|
| 258 |
+
print(f"β Error fetching tweets: {e}")
|
| 259 |
+
return {"videos": []}
|
| 260 |
+
|
| 261 |
+
def get_account_stats(self, account_id: str, start_date: str = None, end_date: str = None) -> Dict[str, Any]:
|
| 262 |
+
"""Get account stats (followers count etc)."""
|
| 263 |
+
client = self.authenticate(account_id)
|
| 264 |
+
if not client:
|
| 265 |
+
return {}
|
| 266 |
+
|
| 267 |
+
try:
|
| 268 |
+
me_resp = client.users.get_me(user_fields=["public_metrics"])
|
| 269 |
+
me = me_resp.data
|
| 270 |
+
metrics = getattr(me, "public_metrics", None)
|
| 271 |
+
|
| 272 |
+
return {
|
| 273 |
+
"total_followers": getattr(metrics, "followers_count", 0) if metrics else 0,
|
| 274 |
+
"total_tweets": getattr(metrics, "tweet_count", 0) if metrics else 0,
|
| 275 |
+
"username": getattr(me, "username", "unknown") if me else "unknown"
|
| 276 |
+
}
|
| 277 |
+
except Exception as e:
|
| 278 |
+
print(f"β Error fetching account stats: {e}")
|
| 279 |
+
return {}
|
src/config.py
CHANGED
|
@@ -164,6 +164,10 @@ def load_configuration(force_reload: bool = False) -> Dict[str, Any]:
|
|
| 164 |
"encryption_key": os.getenv("ENCRYPTION_KEY"),
|
| 165 |
"hf_token": os.getenv("HF_TOKEN"),
|
| 166 |
"hf_space_url": os.getenv("HF_SPACE_URL"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
}
|
| 168 |
|
| 169 |
# On-screen CTA options
|
|
|
|
| 164 |
"encryption_key": os.getenv("ENCRYPTION_KEY"),
|
| 165 |
"hf_token": os.getenv("HF_TOKEN"),
|
| 166 |
"hf_space_url": os.getenv("HF_SPACE_URL"),
|
| 167 |
+
|
| 168 |
+
# X (Twitter)
|
| 169 |
+
"x_client_id": os.getenv("X_CLIENT_ID"),
|
| 170 |
+
"x_client_secret": os.getenv("X_CLIENT_SECRET"),
|
| 171 |
}
|
| 172 |
|
| 173 |
# On-screen CTA options
|