import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { initializeApp } from 'firebase/app';
import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from 'firebase/auth';
import {
getFirestore, collection, query, orderBy, onSnapshot, addDoc, Timestamp
} from 'firebase/firestore';
import { Upload, Sparkles, Calendar, Zap, RefreshCw, X, CheckCircle } from 'lucide-react';
// --- Global Variable Access (Canvas Environment) ---
const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : {};
const initialAuthToken = typeof __initial_auth_token !== 'undefined' ? __initial_auth_token : null;
const apiKey = ""; // API Key is provided by the canvas environment if needed
// Utility function for exponential backoff during API calls
const withExponentialBackoff = async (fn, maxRetries = 5, delay = 1000) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, delay * (2 ** i) + Math.random() * 100));
}
}
};
// Converts a File object to a Base64 string for the Gemini API
const fileToBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
// Extracts the base64 data part after the comma (e.g., "data:image/png;base64,DATA...")
const base64String = reader.result.split(',')[1];
resolve(base64String);
};
reader.onerror = error => reject(error);
reader.readAsDataURL(file);
});
};
// --- Firestore Path Utility ---
const getPublicCollectionPath = (collectionName) => {
return `artifacts/${appId}/public/data/${collectionName}`;
};
// --- Main App Component ---
const App = () => {
const [db, setDb] = useState(null);
const [auth, setAuth] = useState(null);
const [userId, setUserId] = useState(null);
const [isAuthReady, setIsAuthReady] = useState(false);
const [error, setError] = useState(null);
// Scheduling State
const [file, setFile] = useState(null);
const [base64File, setBase64File] = useState(null);
const [platform, setPlatform] = useState('Instagram');
const [scheduleTime, setScheduleTime] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [isScheduling, setIsScheduling] = useState(false);
const [schedules, setSchedules] = useState([]);
// Generated Content State
const [generatedTitle, setGeneratedTitle] = useState('');
const [generatedDescription, setGeneratedDescription] = useState('');
const [generatedHashtags, setGeneratedHashtags] = useState([]);
// --- 1. Firebase Initialization and Authentication ---
useEffect(() => {
if (Object.keys(firebaseConfig).length === 0) {
setError('Firebase configuration is missing.');
return;
}
try {
const firebaseApp = initializeApp(firebaseConfig);
const firestoreDb = getFirestore(firebaseApp);
const firebaseAuth = getAuth(firebaseApp);
setDb(firestoreDb);
setAuth(firebaseAuth);
// Authentication Listener
const unsubscribe = onAuthStateChanged(firebaseAuth, async (user) => {
if (user) {
setUserId(user.uid);
} else {
// Sign in anonymously if no token is available
try {
if (initialAuthToken) {
await signInWithCustomToken(firebaseAuth, initialAuthToken);
} else {
await signInAnonymously(firebaseAuth);
}
} catch (e) {
console.error("Authentication failed:", e);
setError("Failed to authenticate user.");
}
}
setIsAuthReady(true);
});
return () => unsubscribe();
} catch (e) {
console.error("Firebase initialization failed:", e);
setError("Firebase failed to initialize. Check config.");
}
}, []);
// --- 2. Real-time Data Fetching (Schedules) ---
useEffect(() => {
if (!isAuthReady || !db) return;
const path = getPublicCollectionPath('schedules');
const q = query(collection(db, path), orderBy('scheduleTime', 'asc'));
const unsubscribe = onSnapshot(q, (snapshot) => {
const fetchedSchedules = snapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
scheduleTime: doc.data().scheduleTime.toDate().toISOString(), // Convert Timestamp to string
}));
setSchedules(fetchedSchedules);
}, (err) => {
console.error("Error fetching schedules:", err);
setError("Could not load schedules from the database.");
});
return () => unsubscribe();
}, [isAuthReady, db]);
// --- 3. Handlers for UI and File Processing ---
const handleFileChange = async (event) => {
const selectedFile = event.target.files[0];
if (selectedFile) {
setFile(selectedFile);
setGeneratedTitle('');
setGeneratedDescription('');
setGeneratedHashtags([]);
try {
const base64 = await fileToBase64(selectedFile);
setBase64File(base64);
} catch (e) {
console.error("Error converting file to Base64:", e);
setError("Failed to process file. Please try again.");
setFile(null);
setBase64File(null);
}
}
};
const handleReset = () => {
setFile(null);
setBase64File(null);
setGeneratedTitle('');
setGeneratedDescription('');
setGeneratedHashtags([]);
setPlatform('Instagram');
setScheduleTime('');
// Reset file input value
document.getElementById('file-upload-input').value = '';
};
// --- 4. Gemini API Call for Content Generation ---
const generateContent = useCallback(async () => {
if (!file || !base64File) {
setError("Please upload a file first.");
return;
}
setIsGenerating(true);
setError(null);
// Determine MIME type based on file extension or type
let mimeType = file.type || (file.name.endsWith('.mp4') ? 'video/mp4' : (file.name.endsWith('.jpg') || file.name.endsWith('.jpeg') ? 'image/jpeg' : 'image/png'));
// Use a general image/video prompt for the LLM
const userPrompt = `Analyze the uploaded media (image or video) and generate a compelling, SEO-friendly title, a detailed description (max 200 characters), and 8 relevant hashtags for social media scheduling, specifically targeting the ${platform} platform. Return the response as a single JSON object.`;
const systemPrompt = "You are a world-class social media content strategist and SEO specialist. Your task is to generate highly engaging, concise, and platform-optimized text content based on the provided media. Ensure the description is engaging and the title is click-worthy. The output must be valid JSON matching the provided schema.";
const payload = {
contents: [
{
role: "user",
parts: [
{ text: userPrompt },
{
inlineData: {
mimeType: mimeType,
data: base64File
}
}
]
}
],
systemInstruction: {
parts: [{ text: systemPrompt }]
},
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: "OBJECT",
properties: {
"title": { "type": "STRING", "description": "A compelling, SEO-friendly title." },
"description": { "type": "STRING", "description": "A concise, engaging description, max 200 characters." },
"hashtags": {
"type": "ARRAY",
"description": "An array of 8 highly relevant and trending hashtags.",
"items": { "type": "STRING" }
}
},
"required": ["title", "description", "hashtags"]
}
}
};
try {
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-09-2025:generateContent?key=${apiKey}`;
const response = await withExponentialBackoff(() => fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}));
if (!response.ok) {
const errText = await response.text();
throw new Error(`API call failed: ${response.status} - ${errText}`);
}
const result = await response.json();
const jsonText = result.candidates?.[0]?.content?.parts?.[0]?.text;
if (jsonText) {
const generatedContent = JSON.parse(jsonText);
setGeneratedTitle(generatedContent.title || '');
setGeneratedDescription(generatedContent.description || '');
// Ensure hashtags are strings and clean them up (remove # if present)
const cleanedHashtags = (generatedContent.hashtags || [])
.map(tag => typeof tag === 'string' ? tag.replace(/#/g, '') : '')
.filter(tag => tag.length > 0)
.slice(0, 8); // Limit to 8
setGeneratedHashtags(cleanedHashtags);
} else {
throw new Error("Invalid response structure from the content generation model.");
}
} catch (e) {
console.error("Content generation error:", e);
setError(`Failed to generate content: ${e.message}`);
} finally {
setIsGenerating(false);
}
}, [file, base64File, platform]);
// --- 5. Scheduling to Firestore ---
const scheduleContent = async () => {
if (!db || !userId) {
setError("Database or user ID not available. Please wait for authentication.");
return;
}
if (!generatedTitle || !scheduleTime) {
setError("Please generate content and set a schedule date/time first.");
return;
}
setIsScheduling(true);
setError(null);
try {
const scheduleTimestamp = Timestamp.fromDate(new Date(scheduleTime));
const scheduleData = {
userId: userId,
platform: platform,
scheduleTime: scheduleTimestamp,
title: generatedTitle,
description: generatedDescription,
hashtags: generatedHashtags,
createdAt: Timestamp.now(),
};
const path = getPublicCollectionPath('schedules');
await addDoc(collection(db, path), scheduleData);
// Clear content after successful scheduling
handleReset();
} catch (e) {
console.error("Error scheduling content:", e);
setError(`Failed to schedule content: ${e.message}`);
} finally {
setIsScheduling(false);
}
};
// --- Rendering Logic ---
const canGenerate = !!file && !isGenerating;
const canSchedule = generatedTitle && scheduleTime && !isScheduling;
const isReady = isAuthReady && db;
if (!isReady) {
return (
Initializing Social Scheduler...
);
}
const platformOptions = ['Instagram', 'YouTube', 'TikTok', 'Facebook', 'X (Twitter)', 'LinkedIn'];
const getMimeTypeIcon = (fileName) => {
if (!fileName) return "File";
const lowerName = fileName.toLowerCase();
if (lowerName.endsWith('.mp4') || lowerName.endsWith('.mov') || lowerName.endsWith('.avi')) {
return 'Video';
}
if (lowerName.endsWith('.jpg') || lowerName.endsWith('.jpeg') || lowerName.endsWith('.png') || lowerName.endsWith('.gif')) {
return 'Image';
}
return 'Media';
};
const formatDate = (isoString) => {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
const globalStyles = `
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
`;
return (
{/* Fix: Use dangerouslySetInnerHTML for global styles */}
{error && (
Error:
{error}
)}
{/* --- INPUT & GENERATION CARD (Col 1) --- */}
1. Upload & Configure
{/* File Upload Area */}
document.getElementById('file-upload-input').click()}>
{file ? (
) : (
)}
{file ? (
<>
File Ready:
{file.name} ({getMimeTypeIcon(file.name)})
>
) : (
<>
Click to upload or drag and drop
>
)}
PNG, JPG, MP4, MOV up to 1MB (simulated limit)
{/* Platform Selector */}
{/* Generate Button */}
{file && (
)}
{/* --- GENERATED CONTENT & SCHEDULE (Col 2) --- */}
2. Generated Content
{/* Title */}
setGeneratedTitle(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-2"
placeholder="Enter or edit generated title"
/>
{/* Hashtags */}
{generatedHashtags.length > 0 ? generatedHashtags.map((tag, index) => (
#{tag}
)) : (
Awaiting generation...
)}
Total: {generatedHashtags.length}
{/* Description */}
3. Schedule Time
{/* Date/Time Selector */}
setScheduleTime(e.target.value)}
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
/>
{/* Schedule Button */}
{/* --- SCHEDULES LIST (Col 3) --- */}
Scheduled Posts ({schedules.length})
{schedules.length === 0 ? (
No posts have been scheduled yet. Start automating!
) : (
)}
);
};
export default App;
Comments
Post a Comment