diff --git a/.env.example b/.env.example index 3b253de..87fff22 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ GITHUB_TOKEN= DISCORD_WEBHOOK_URL= CONTACT_DISCORD_WEBHOOK_URL= COMMENTS_DISCORD_WEBHOOK_URL= +INSTAGRAM_CLIENT_ID= +INSTAGRAM_CLIENT_SECRET= # Optional contact-form email delivery. GMAIL_USER= diff --git a/src/app/admin/database/page.tsx b/src/app/admin/database/page.tsx index 323ac7c..aec0e2e 100644 --- a/src/app/admin/database/page.tsx +++ b/src/app/admin/database/page.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import { motion } from 'framer-motion' import { Database, Play, RefreshCw, CheckCircle2, ShieldCheck, Terminal, Layers, Table, HardDrive, Zap, Code, AlertTriangle } from 'lucide-react' import { supabase } from '@/lib/supabase' @@ -19,6 +19,20 @@ WHERE table_schema = 'public'; -- 1-Click Master Schema Migration available via /api/setup-db` +const MANAGED_TABLES: TableInfo[] = [ + { name: 'projects', rowCount: 0, status: 'ACTIVE', description: 'Portfolio showcase items, tech stacks, GitHub links' }, + { name: 'certificates', rowCount: 0, status: 'ACTIVE', description: 'Verified credentials, issuers, credential IDs' }, + { name: 'comments', rowCount: 0, status: 'ACTIVE', description: 'Public visitor testimonials & comment feed' }, + { name: 'technologies', rowCount: 0, status: 'ACTIVE', description: 'Tech stack badges, proficiency levels, icons' }, + { name: 'scene3d_words', rowCount: 0, status: 'ACTIVE', description: '3D WebGL particle cloud words' }, + { name: 'portfolio_settings', rowCount: 0, status: 'ACTIVE', description: 'Global site config, intro music, Spotify URLs' }, + { name: 'instagram_accounts', rowCount: 0, status: 'ACTIVE', description: 'Permissioned Instagram account profile and sync metadata' }, + { name: 'instagram_media', rowCount: 0, status: 'ACTIVE', description: 'Connected account posts, carousels, and Reels' }, + { name: 'instagram_connections', rowCount: 0, status: 'ACTIVE', description: 'Admin-only OAuth token, scopes, and expiry metadata' }, + { name: 'visitors', rowCount: 0, status: 'ACTIVE', description: 'Real-time visitor IP geolocations & browser user agents' }, + { name: 'analytics', rowCount: 0, status: 'ACTIVE', description: 'Performance telemetry & Web Vitals benchmarks' }, +] + export default function AdminDatabasePage() { const [sqlQuery, setSqlQuery] = useState(DEFAULT_SQL_QUERY) const [executing, setExecuting] = useState(false) @@ -26,23 +40,13 @@ export default function AdminDatabasePage() { const [message, setMessage] = useState('') const [queryResult, setQueryResult] = useState(null) const [activeTab, setActiveTab] = useState<'tables' | 'editor' | 'master_sql'>('tables') - const [tables, setTables] = useState([ - { name: 'projects', rowCount: 0, status: 'ACTIVE', description: 'Portfolio showcase items, tech stacks, GitHub links' }, - { name: 'certificates', rowCount: 0, status: 'ACTIVE', description: 'Verified credentials, issuers, credential IDs' }, - { name: 'comments', rowCount: 0, status: 'ACTIVE', description: 'Public visitor testimonials & comment feed' }, - { name: 'technologies', rowCount: 0, status: 'ACTIVE', description: 'Tech stack badges, proficiency levels, icons' }, - { name: 'scene3d_words', rowCount: 0, status: 'ACTIVE', description: '3D WebGL particle cloud words' }, - { name: 'portfolio_settings', rowCount: 0, status: 'ACTIVE', description: 'Global site config, intro music, Spotify URLs' }, - { name: 'instagram_posts', rowCount: 0, status: 'ACTIVE', description: 'Instagram @sahad_____sha feed items, likes, permalinks' }, - { name: 'visitors', rowCount: 0, status: 'ACTIVE', description: 'Real-time visitor IP geolocations & browser user agents' }, - { name: 'analytics', rowCount: 0, status: 'ACTIVE', description: 'Performance telemetry & Web Vitals benchmarks' }, - ]) + const [tables, setTables] = useState(MANAGED_TABLES) - const fetchTableMetrics = async () => { + const fetchTableMetrics = useCallback(async () => { setMessage('') try { const updated = await Promise.all( - tables.map(async (t) => { + MANAGED_TABLES.map(async (t) => { try { const { count, error } = await supabase.from(t.name).select('*', { count: 'exact', head: true }) if (error) { @@ -58,7 +62,7 @@ export default function AdminDatabasePage() { } catch (e: any) { setMessage(`Error fetching database metrics: ${e.message}`) } - } + }, []) useEffect(() => { fetchTableMetrics() @@ -75,7 +79,7 @@ export default function AdminDatabasePage() { return () => { supabase.removeChannel(channel) } - }, []) + }, [fetchTableMetrics]) // Execute SQL Console const handleExecuteSQL = async () => { diff --git a/src/app/admin/instagram/page.tsx b/src/app/admin/instagram/page.tsx index 6a18111..0ee349f 100644 --- a/src/app/admin/instagram/page.tsx +++ b/src/app/admin/instagram/page.tsx @@ -2,33 +2,51 @@ import React, { useState, useEffect } from 'react' import { motion } from 'framer-motion' -import { Instagram, Plus, Trash2, Edit3, Heart, MessageCircle, ExternalLink, CheckCircle2, Sparkles, RefreshCw, Save, LogIn, Key, ShieldCheck, Zap, AlertCircle, HelpCircle, Facebook, Link2, Cpu, Terminal, Shield, FileText } from 'lucide-react' +import { Instagram, Plus, Trash2, Edit3, Heart, MessageCircle, ExternalLink, CheckCircle2, Sparkles, RefreshCw, Save, LogIn, Key, ShieldCheck, Zap, AlertCircle, HelpCircle, Facebook, Link2, Cpu, Terminal, Shield, FileText, Play } from 'lucide-react' import { supabase } from '@/lib/supabase' interface InstagramPost { id: string - image_url: string - caption: string - likes_count: number + account_id: string + instagram_media_id: string + media_type: string + media_product_type: string + media_url: string | null + thumbnail_url: string | null + permalink: string + caption: string | null + like_count: number comments_count: number - post_url: string + posted_at: string | null +} + +interface InstagramAccount { + id: string + username: string + name: string | null + profile_picture_url: string | null + followers_count: number + follows_count: number + media_count: number + last_synced_at: string | null } const META_GRAPH_VERSION = 'v26.0' const FACEBOOK_LOGIN_SCOPES = 'public_profile,email' -const INSTAGRAM_LOGIN_SCOPES = 'instagram_business_basic' export default function AdminInstagramPage() { const [posts, setPosts] = useState([]) + const [account, setAccount] = useState(null) const [loading, setLoading] = useState(true) const [syncing, setSyncing] = useState(false) const [saving, setSaving] = useState(false) const [message, setMessage] = useState('') const [isConnected, setIsConnected] = useState(false) - // Instagram Graph API & Meta MCP Server Credentials + // Facebook Login is a separate identity check. Instagram OAuth credentials + // are server-only and come from INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET. const [instagramAccount, setInstagramAccount] = useState('sahad_____sha') - const [appId, setAppId] = useState('1679398459977278') + const [facebookAppId, setFacebookAppId] = useState('1679398459977278') const [authType, setAuthType] = useState<'instagram_login' | 'direct_token'>('instagram_login') const [accessToken, setAccessToken] = useState('') const [syncInterval, setSyncInterval] = useState('6h') @@ -48,10 +66,18 @@ export default function AdminInstagramPage() { const fetchPosts = async () => { setLoading(true) try { - const { data, error } = await supabase.from('instagram_posts').select('*').order('created_at', { ascending: false }) - if (!error && data) { - setPosts(data) + const [{ data: accountData }, { data: mediaData, error }] = await Promise.all([ + supabase.from('instagram_accounts').select('*').eq('is_active', true).order('updated_at', { ascending: false }).limit(1).maybeSingle(), + supabase.from('instagram_media').select('*').eq('is_visible', true).order('posted_at', { ascending: false }), + ]) + if (accountData) { + setAccount(accountData as InstagramAccount) + setIsConnected(true) + } else { + setAccount(null) + setIsConnected(false) } + if (!error && mediaData) setPosts(mediaData as InstagramPost[]) } catch (e) { console.error(e) } finally { @@ -67,7 +93,7 @@ export default function AdminInstagramPage() { const params = new URLSearchParams(window.location.search) if (params.get('status') === 'connected') { setIsConnected(true) - setMessage('✅ Real-Time Instagram OAuth Connected! Live posts synced to database.') + setMessage(`✅ @${params.get('account') || 'Instagram'} connected. Synced ${params.get('posts') || 0} posts and ${params.get('reels') || 0} Reels.`) } else if (params.get('code')) { setMessage('⚡ Received Instagram Authorization Code! Auto-exchanging access token...') } else if (params.get('error')) { @@ -82,12 +108,16 @@ export default function AdminInstagramPage() { setMessage('') try { - const res = await fetch(`/api/instagram-feed?username=${encodeURIComponent(instagramAccount)}&token=${encodeURIComponent(accessToken)}`) + const res = await fetch('/api/instagram-feed', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(accessToken.trim() ? { token: accessToken.trim() } : {}), + }) const data = await res.json() if (data.success) { setIsConnected(true) - setMessage(`⚡ Live Instagram Feed Reader successfully fetched & synced ${data.count} posts from @${data.username} via ${data.source}!`) + setMessage(`⚡ Synced ${data.posts} posts and ${data.reels} Reels from @${data.username} through the permissioned Instagram Graph API.`) fetchPosts() } else { setMessage(`Sync error: ${data.message || 'Failed to read Instagram feed'}`) @@ -109,7 +139,7 @@ export default function AdminInstagramPage() { if (window.FB) { // @ts-ignore window.FB.init({ - appId: appId || '1679398459977278', + appId: facebookAppId || '1679398459977278', cookie: true, xfbml: true, version: META_GRAPH_VERSION, @@ -123,7 +153,7 @@ export default function AdminInstagramPage() { js.src = 'https://connect.facebook.net/en_US/sdk.js' document.body.appendChild(js) } - }, [appId]) + }, [facebookAppId]) // Facebook Login verifies the Meta identity only. Instagram professional // account access is authorized separately through Instagram Login. @@ -148,14 +178,6 @@ export default function AdminInstagramPage() { // Handle OAuth Connect Account with Vercel Connect & Real Callback const handleConnectAccount = async () => { - const cleanAppId = appId.trim() - - if (!cleanAppId) { - setShowSetupGuide(true) - setMessage('⚠️ Please enter your Meta/Instagram App ID below to authorize OAuth, or paste your User Access Token directly.') - return - } - if (authType === 'direct_token') { if (!accessToken.trim()) { setMessage('⚠️ Paste an Instagram access token before connecting.') @@ -165,11 +187,7 @@ export default function AdminInstagramPage() { return } - const redirectUri = window.location.origin + '/api/instagram-callback' - const authUrl = `https://www.instagram.com/oauth/authorize?enable_fb_login=0&force_authentication=1&client_id=${cleanAppId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(INSTAGRAM_LOGIN_SCOPES)}&response_type=code` - - // Instagram Login is a separate use case from Facebook Login. - window.location.href = authUrl + window.location.href = '/api/instagram-auth' } const handleAddPost = async (e: React.FormEvent) => { @@ -179,13 +197,24 @@ export default function AdminInstagramPage() { setMessage('') try { - const { data, error } = await supabase.from('instagram_posts').insert([ + if (!account) { + setMessage('Connect an Instagram account before adding custom media.') + return + } + const { data, error } = await supabase.from('instagram_media').insert([ { - image_url: imageUrl.trim(), + account_id: account.id, + instagram_media_id: `manual-${crypto.randomUUID()}`, + media_type: 'IMAGE', + media_product_type: 'FEED', + media_url: imageUrl.trim(), caption: caption.trim() || 'Official Instagram Post @sahad_____sha', - likes_count: Number(likes), + like_count: Number(likes), comments_count: Number(comments), - post_url: postUrl.trim(), + permalink: postUrl.trim(), + username: account.username, + posted_at: new Date().toISOString(), + is_visible: true, }, ]).select() @@ -207,7 +236,7 @@ export default function AdminInstagramPage() { const handleDeletePost = async (id: string) => { if (!confirm('Are you sure you want to delete this Instagram post entry?')) return try { - const { error } = await supabase.from('instagram_posts').delete().eq('id', id) + const { error } = await supabase.from('instagram_media').delete().eq('id', id) if (!error) { setPosts(posts.filter((p) => p.id !== id)) setMessage('🗑️ Post deleted.') @@ -259,13 +288,13 @@ export default function AdminInstagramPage() {
-

Account: @{instagramAccount}

- - CONNECTED +

Account: @{account?.username || instagramAccount}

+ + {account || isConnected ? 'CONNECTED' : 'NOT CONNECTED'}

- Instagram Graph API {META_GRAPH_VERSION} • Followers: 11,355 • Following: 459 + Instagram Graph API {META_GRAPH_VERSION} • Followers: {account?.followers_count ?? 0} • Following: {account?.follows_count ?? 0}

@@ -278,7 +307,7 @@ export default function AdminInstagramPage() { className="flex items-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-5 py-2.5 text-xs font-bold text-white shadow-lg hover:brightness-110 transition" > - {syncing ? 'Auto Syncing Posts...' : 'Auto Sync Profile & Posts'} + {syncing ? 'Syncing Posts & Reels...' : 'Auto Sync Profile, Posts & Reels'}