873
web/frontend/src/pages/paid-content/Dashboard.tsx
Normal file
873
web/frontend/src/pages/paid-content/Dashboard.tsx
Normal file
@@ -0,0 +1,873 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useBreadcrumb } from '../../hooks/useBreadcrumb'
|
||||
import { breadcrumbConfig } from '../../config/breadcrumbConfig'
|
||||
import {
|
||||
Users,
|
||||
User,
|
||||
FileText,
|
||||
HardDrive,
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
ArrowUpRight,
|
||||
XCircle,
|
||||
Download,
|
||||
Gem,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
Image as ImageIcon,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Cookie,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import { api, getErrorMessage, PaidContentService, PaidContentActiveTask, PaidContentPost, PlatformCredential } from '../../lib/api'
|
||||
import { formatBytes, formatRelativeTime, formatPlatformName, cleanCaption, THUMB_CACHE_V } from '../../lib/utils'
|
||||
import { notificationManager } from '../../lib/notificationManager'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import BundleLightbox from '../../components/paid-content/BundleLightbox'
|
||||
|
||||
// Use shared types from api.ts
|
||||
type ActiveTask = PaidContentActiveTask
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color = 'blue',
|
||||
subtitle,
|
||||
link,
|
||||
}: {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: React.ElementType
|
||||
color?: 'blue' | 'green' | 'purple' | 'orange' | 'red'
|
||||
subtitle?: string
|
||||
link?: string
|
||||
}) {
|
||||
const colorStyles: Record<string, { card: string; icon: string }> = {
|
||||
blue: {
|
||||
card: 'stat-card-blue shadow-blue-glow',
|
||||
icon: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
green: {
|
||||
card: 'stat-card-green shadow-green-glow',
|
||||
icon: 'bg-emerald-500/20 text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
purple: {
|
||||
card: 'stat-card-purple shadow-purple-glow',
|
||||
icon: 'bg-violet-500/20 text-violet-600 dark:text-violet-400',
|
||||
},
|
||||
orange: {
|
||||
card: 'stat-card-orange shadow-orange-glow',
|
||||
icon: 'bg-amber-500/20 text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
red: {
|
||||
card: 'bg-card border border-red-200 dark:border-red-800',
|
||||
icon: 'bg-red-500/20 text-red-600 dark:text-red-400',
|
||||
},
|
||||
}
|
||||
|
||||
const styles = colorStyles[color]
|
||||
|
||||
const content = (
|
||||
<div className={`p-4 md:p-6 rounded-xl ${styles.card} transition-all duration-200 hover:scale-[1.02] h-full flex flex-col`}>
|
||||
<div className="flex items-start justify-between flex-1">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl ${styles.icon}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{link && (
|
||||
<div className="mt-3 flex items-center text-xs text-primary">
|
||||
<span>View details</span>
|
||||
<ArrowUpRight className="w-3 h-3 ml-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return link ? <Link to={link} className="block h-full">{content}</Link> : content
|
||||
}
|
||||
|
||||
function ServiceStatusCard({ service, credential }: { service: PaidContentService; credential?: PlatformCredential }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const healthCheckMutation = useMutation({
|
||||
mutationFn: () => api.paidContent.checkServiceHealth(service.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['paid-content-services'] })
|
||||
notificationManager.success('Health Check', `${service.name} health check completed`)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
notificationManager.error('Health Check Failed', getErrorMessage(error))
|
||||
},
|
||||
})
|
||||
|
||||
const monitoringMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => api.togglePlatformMonitoring(service.id, enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['platform-credentials'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cookie-health'] })
|
||||
},
|
||||
})
|
||||
|
||||
const statusStyles: Record<string, { bg: string; icon: React.ElementType }> = {
|
||||
healthy: { bg: 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400', icon: CheckCircle },
|
||||
degraded: { bg: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400', icon: AlertCircle },
|
||||
down: { bg: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400', icon: XCircle },
|
||||
unknown: { bg: 'bg-slate-100 dark:bg-slate-500/20 text-slate-700 dark:text-slate-400', icon: AlertCircle },
|
||||
}
|
||||
|
||||
const status = statusStyles[service.health_status || 'unknown'] || statusStyles.unknown
|
||||
const StatusIcon = status.icon
|
||||
const monitoringEnabled = credential?.monitoring_enabled ?? false
|
||||
const cookiesCount = credential?.cookies_count ?? 0
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-2 rounded-lg ${status.bg}`}>
|
||||
<StatusIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">{service.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground capitalize">{service.health_status || 'unknown'}</span>
|
||||
{cookiesCount > 0 && (
|
||||
<span className="flex items-center space-x-1 text-xs text-blue-600 dark:text-blue-400" title={`${cookiesCount} credentials`}>
|
||||
<Cookie className="w-3 h-3" />
|
||||
<span>{cookiesCount}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => monitoringMutation.mutate(!monitoringEnabled)}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
monitoringEnabled
|
||||
? 'text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20'
|
||||
: 'text-slate-400 dark:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title={monitoringEnabled ? 'Monitoring enabled' : 'Monitoring disabled'}
|
||||
>
|
||||
{monitoringEnabled ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => healthCheckMutation.mutate()}
|
||||
disabled={healthCheckMutation.isPending}
|
||||
className="p-1.5 rounded-lg hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
title="Check health"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-muted-foreground ${healthCheckMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<Link
|
||||
to="/scrapers"
|
||||
className="p-1.5 rounded-lg hover:bg-secondary transition-colors"
|
||||
title="Manage credentials"
|
||||
>
|
||||
<Settings className="w-4 h-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{service.last_health_check && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Last checked: {formatRelativeTime(service.last_health_check)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActiveTaskCard({ task }: { task: ActiveTask }) {
|
||||
const getPhaseIcon = () => {
|
||||
switch (task.phase) {
|
||||
case 'fetching':
|
||||
return <RefreshCw className="w-4 h-4 animate-spin text-blue-500" />
|
||||
case 'processing':
|
||||
return <Loader2 className="w-4 h-4 animate-spin text-amber-500" />
|
||||
case 'downloading':
|
||||
return <Download className="w-4 h-4 animate-pulse text-emerald-500" />
|
||||
default:
|
||||
return <Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
}
|
||||
}
|
||||
|
||||
const getPhaseLabel = () => {
|
||||
switch (task.phase) {
|
||||
case 'fetching':
|
||||
return 'Fetching posts'
|
||||
case 'processing':
|
||||
return 'Processing'
|
||||
case 'downloading':
|
||||
return 'Downloading'
|
||||
default:
|
||||
return 'Working'
|
||||
}
|
||||
}
|
||||
|
||||
const progressPercent = task.total_files
|
||||
? Math.round(((task.downloaded || 0) / task.total_files) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 bg-secondary/50 rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 rounded-xl bg-primary/10">
|
||||
{getPhaseIcon()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-foreground">{task.username}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatPlatformName(task.platform)} • {formatPlatformName(task.service)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary/10 text-primary">
|
||||
{getPhaseLabel()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-foreground">{task.status}</p>
|
||||
|
||||
{task.phase === 'downloading' && task.total_files && (
|
||||
<div className="bg-background/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">Download Progress</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{task.downloaded || 0} / {task.total_files} files ({progressPercent}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary to-emerald-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active Downloads */}
|
||||
{task.active_downloads && task.active_downloads.length > 0 && (
|
||||
<div className="pt-2 border-t border-border/50 space-y-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Currently downloading ({task.active_count || task.active_downloads.length}):
|
||||
</span>
|
||||
{task.active_downloads.slice(0, 3).map((dl, idx) => (
|
||||
<div key={idx} className="text-xs">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-foreground truncate flex-1 mr-2" title={dl.name}>
|
||||
{dl.name.length > 35 ? dl.name.slice(0, 35) + '...' : dl.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
{formatBytes(dl.progress)}{dl.size ? ` / ${formatBytes(dl.size)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{dl.size && (
|
||||
<div className="h-1 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all duration-200"
|
||||
style={{ width: `${dl.size > 0 ? Math.min(100, (dl.progress / dl.size) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{task.active_downloads.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{task.active_downloads.length - 3} more...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.phase === 'fetching' && task.posts_fetched !== undefined && (
|
||||
<div className="bg-background/50 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{task.posts_fetched}</span> posts fetched so far
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentPostsCard({ posts, onPostClick, onAttachmentClick }: {
|
||||
posts: PaidContentPost[]
|
||||
onPostClick: (post: PaidContentPost) => void
|
||||
onAttachmentClick: (post: PaidContentPost, attachmentIndex: number) => void
|
||||
}) {
|
||||
if (!posts || posts.length === 0) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
Recent Posts
|
||||
</h2>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No posts yet</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Truncate text to ~2 lines
|
||||
const truncateText = (text: string | null, maxLength: number = 80) => {
|
||||
if (!text) return ''
|
||||
// Decode HTML entities
|
||||
const decoded = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
if (decoded.length <= maxLength) return decoded
|
||||
return decoded.slice(0, maxLength).trim() + '...'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
Recent Posts
|
||||
</h2>
|
||||
<Link to="/paid-content/feed" className="text-xs text-primary hover:underline flex items-center gap-1">
|
||||
View all <ArrowUpRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{posts.map((post) => {
|
||||
const completedAttachments = (post.attachments || []).filter(
|
||||
(a) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
|
||||
)
|
||||
const displayAttachments = completedAttachments.slice(0, 4)
|
||||
const extraCount = completedAttachments.length - 4
|
||||
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
className="bg-secondary/30 rounded-lg overflow-hidden hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
{/* Post Header - Clickable */}
|
||||
<div
|
||||
className="p-3 cursor-pointer"
|
||||
onClick={() => onPostClick(post)}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-pink-500 to-violet-500 p-0.5 flex-shrink-0">
|
||||
<div className="w-full h-full rounded-full overflow-hidden bg-card">
|
||||
{post.profile_image_url ? (
|
||||
<img
|
||||
src={post.profile_image_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-secondary">
|
||||
<User className="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 leading-tight">
|
||||
<span className="text-sm font-medium text-foreground truncate block">
|
||||
{post.display_name || post.username || 'Unknown'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground -mt-0.5 block">@{post.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 min-h-[2.5rem]">
|
||||
{truncateText(cleanCaption(post.content || post.title || ''), 100) || 'No description'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{formatRelativeTime(post.published_at || post.added_at || '')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Attachments Grid - Fixed height row */}
|
||||
{displayAttachments.length > 0 && (
|
||||
<div className="flex gap-1 h-16 overflow-hidden px-3 pb-2">
|
||||
{displayAttachments.map((att, idx) => {
|
||||
const isVideo = att.file_type === 'video'
|
||||
return (
|
||||
<div
|
||||
key={att.id}
|
||||
className={`h-16 flex-shrink-0 bg-slate-800 relative cursor-pointer group rounded ${
|
||||
isVideo ? 'w-28' : 'w-16'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onAttachmentClick(post, idx)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/api/paid-content/files/thumbnail/${att.id}?size=small&${att.file_hash ? `v=${att.file_hash.slice(0, 8)}` : THUMB_CACHE_V}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
{isVideo && (
|
||||
<div className="absolute top-1 left-1 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] flex items-center gap-0.5">
|
||||
<Play className="w-2.5 h-2.5" fill="currentColor" />
|
||||
Video
|
||||
</div>
|
||||
)}
|
||||
{idx === 3 && extraCount > 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<span className="text-white text-xs font-medium">+{extraCount}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No attachments placeholder - only show for posts that should have media */}
|
||||
{displayAttachments.length === 0 && post.has_attachments === 1 && (
|
||||
<div className="h-16 bg-secondary/50 flex items-center justify-center mx-3 mb-2 rounded">
|
||||
<ImageIcon className="w-6 h-6 text-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PaidContentDashboard() {
|
||||
useBreadcrumb(breadcrumbConfig['/paid-content'])
|
||||
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [retryingIds, setRetryingIds] = useState<number[]>([])
|
||||
|
||||
// Lightbox state for recent posts
|
||||
const [lightboxPost, setLightboxPost] = useState<PaidContentPost | null>(null)
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0)
|
||||
|
||||
// Poll for active sync tasks (like main dashboard does)
|
||||
const { data: activeSyncs } = useQuery({
|
||||
queryKey: ['paid-content-active-syncs'],
|
||||
queryFn: async () => {
|
||||
const syncs = await api.paidContent.getActiveSyncs()
|
||||
if (syncs && syncs.length > 0) {
|
||||
console.log('[PaidContent Dashboard] Active syncs:', syncs)
|
||||
}
|
||||
return syncs
|
||||
},
|
||||
refetchInterval: 2000, // Poll every 2 seconds for real-time updates
|
||||
staleTime: 1000,
|
||||
})
|
||||
|
||||
// Refetch stats more frequently when syncs are active
|
||||
const hasActiveSyncs = activeSyncs && activeSyncs.length > 0
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['paid-content-stats'],
|
||||
queryFn: () => api.paidContent.getDashboardStats(),
|
||||
refetchInterval: hasActiveSyncs ? 3000 : 30000, // Poll every 3s during active syncs
|
||||
})
|
||||
|
||||
const { data: services, isLoading: servicesLoading } = useQuery({
|
||||
queryKey: ['paid-content-services'],
|
||||
queryFn: () => api.paidContent.getServices(),
|
||||
refetchInterval: 60000, // Refresh cached status every minute
|
||||
})
|
||||
|
||||
const { data: credentialsData } = useQuery({
|
||||
queryKey: ['platform-credentials'],
|
||||
queryFn: () => api.getPlatformCredentials(),
|
||||
refetchInterval: 60000,
|
||||
})
|
||||
|
||||
// Map platform credentials by service id for quick lookup
|
||||
const credentialsByService = (credentialsData?.platforms ?? []).reduce<Record<string, PlatformCredential>>(
|
||||
(acc, p) => { acc[p.id] = p; return acc },
|
||||
{}
|
||||
)
|
||||
|
||||
const { data: failedDownloads, isLoading: failedLoading } = useQuery({
|
||||
queryKey: ['paid-content-failed'],
|
||||
queryFn: () => api.paidContent.getFailedDownloads(),
|
||||
refetchInterval: hasActiveSyncs ? 5000 : 60000, // Poll every 5s during active syncs
|
||||
})
|
||||
|
||||
// Fetch recent posts for the Recent Posts card
|
||||
const { data: recentPostsData } = useQuery({
|
||||
queryKey: ['paid-content-recent-posts'],
|
||||
queryFn: () => api.paidContent.getFeed({
|
||||
limit: 4,
|
||||
sort_by: 'published_at',
|
||||
sort_order: 'desc',
|
||||
pinned_first: false, // Don't prioritize pinned posts in dashboard
|
||||
skip_pinned: true, // Exclude pinned posts from recent posts
|
||||
}),
|
||||
refetchInterval: hasActiveSyncs ? 10000 : 60000, // Refresh more often during active syncs
|
||||
})
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (ids: number[]) => api.paidContent.retryFailedDownloads(ids),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['paid-content-failed'] })
|
||||
notificationManager.success('Retry Queued', 'Failed downloads queued for retry')
|
||||
setRetryingIds([])
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
notificationManager.error('Retry Failed', getErrorMessage(error))
|
||||
},
|
||||
})
|
||||
|
||||
const handleRetry = (ids: number[]) => {
|
||||
setRetryingIds(ids)
|
||||
retryMutation.mutate(ids)
|
||||
}
|
||||
|
||||
const syncAllMutation = useMutation({
|
||||
mutationFn: () => api.paidContent.syncAllCreators(),
|
||||
onSuccess: () => {
|
||||
notificationManager.success('Sync Started', 'Syncing all creators in background')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
notificationManager.error('Sync Failed', getErrorMessage(error))
|
||||
},
|
||||
})
|
||||
|
||||
// Unread messages count
|
||||
const { data: unreadMessagesData } = useQuery({
|
||||
queryKey: ['paid-content-unread-messages-count'],
|
||||
queryFn: () => api.paidContent.getUnreadMessagesCount(),
|
||||
staleTime: 60000,
|
||||
refetchInterval: 60000,
|
||||
})
|
||||
const unreadMessagesCount = unreadMessagesData?.count ?? 0
|
||||
|
||||
// Mark all messages read mutation
|
||||
const markAllMessagesReadMutation = useMutation({
|
||||
mutationFn: () => api.paidContent.markAllMessagesRead(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['paid-content-unread-messages-count'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Unviewed posts count
|
||||
const { data: unviewedData } = useQuery({
|
||||
queryKey: ['paid-content-unviewed-count'],
|
||||
queryFn: () => api.paidContent.getUnviewedCount(),
|
||||
staleTime: 60000,
|
||||
refetchInterval: 60000,
|
||||
})
|
||||
const unviewedCount = unviewedData?.count ?? 0
|
||||
|
||||
// Mark all posts viewed mutation
|
||||
const markAllViewedMutation = useMutation({
|
||||
mutationFn: () => api.paidContent.markAllViewed(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['paid-content-unviewed-count'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['paid-content-recent-posts'] })
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = statsLoading || servicesLoading || failedLoading
|
||||
|
||||
// Handlers for Recent Posts card
|
||||
const handlePostClick = (post: PaidContentPost) => {
|
||||
// Navigate to feed and open this specific post
|
||||
navigate(`/paid-content/feed?post=${post.id}`)
|
||||
}
|
||||
|
||||
const handleAttachmentClick = (post: PaidContentPost, attachmentIndex: number) => {
|
||||
setLightboxPost(post)
|
||||
setLightboxIndex(attachmentIndex)
|
||||
}
|
||||
|
||||
// Get completed attachments for lightbox
|
||||
const lightboxAttachments = lightboxPost?.attachments?.filter(
|
||||
(a) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
|
||||
) || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Lightbox for Recent Posts attachments */}
|
||||
{lightboxPost && lightboxAttachments.length > 0 && (
|
||||
<BundleLightbox
|
||||
post={lightboxPost}
|
||||
attachments={lightboxAttachments}
|
||||
currentIndex={lightboxIndex}
|
||||
onClose={() => setLightboxPost(null)}
|
||||
onNavigate={setLightboxIndex}
|
||||
onViewPost={() => {
|
||||
setLightboxPost(null)
|
||||
navigate(`/paid-content/feed?post=${lightboxPost.id}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
|
||||
<Gem className="w-8 h-8 text-violet-500" />
|
||||
Paid Content
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400 mt-1">
|
||||
Track and download content from creators
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => syncAllMutation.mutate()}
|
||||
disabled={syncAllMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${syncAllMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{syncAllMutation.isPending ? 'Syncing...' : 'Sync All Creators'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Unread messages banner */}
|
||||
{unreadMessagesCount > 0 && (
|
||||
<div className="flex items-center justify-between gap-3 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 rounded-lg shadow-sm border border-violet-200 dark:border-violet-800 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold shrink-0">
|
||||
{unreadMessagesCount}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-violet-700 dark:text-violet-300">
|
||||
{unreadMessagesCount === 1 ? '1 unread message' : `${unreadMessagesCount} unread messages`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Link
|
||||
to="/paid-content/messages"
|
||||
className="px-3 py-1.5 rounded-lg bg-violet-600 text-white text-sm font-medium hover:bg-violet-700 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
View messages
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => markAllMessagesReadMutation.mutate()}
|
||||
disabled={markAllMessagesReadMutation.isPending}
|
||||
className="px-3 py-1.5 rounded-md text-violet-700 dark:text-violet-300 text-sm hover:bg-violet-100 dark:hover:bg-violet-900/30 transition-colors"
|
||||
>
|
||||
{markAllMessagesReadMutation.isPending ? 'Marking...' : 'Mark all read'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unviewed posts banner */}
|
||||
{unviewedCount > 0 && (
|
||||
<div className="flex items-center justify-between gap-3 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-lg shadow-sm border border-blue-200 dark:border-blue-800 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 text-white text-sm font-bold shrink-0">
|
||||
{unviewedCount}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
{unviewedCount === 1 ? '1 unviewed post' : `${unviewedCount} unviewed posts`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Link
|
||||
to="/paid-content/feed?unviewed=true"
|
||||
className="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
View unviewed
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => markAllViewedMutation.mutate()}
|
||||
disabled={markAllViewedMutation.isPending}
|
||||
className="px-3 py-1.5 rounded-md text-blue-700 dark:text-blue-300 text-sm hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
|
||||
>
|
||||
{markAllViewedMutation.isPending ? 'Marking...' : 'Mark all viewed'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="Tracked Creators"
|
||||
value={stats?.total_creators || 0}
|
||||
icon={Users}
|
||||
color="blue"
|
||||
link="/paid-content/creators"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Posts"
|
||||
value={stats?.total_posts || 0}
|
||||
icon={FileText}
|
||||
color="purple"
|
||||
subtitle={`${stats?.downloaded_posts || 0} downloaded`}
|
||||
link="/paid-content/feed"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Files"
|
||||
value={stats?.total_files || 0}
|
||||
icon={Download}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Storage Used"
|
||||
value={formatBytes(stats?.total_size_bytes || 0)}
|
||||
icon={HardDrive}
|
||||
color="orange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Posts */}
|
||||
<RecentPostsCard
|
||||
posts={recentPostsData?.posts || []}
|
||||
onPostClick={handlePostClick}
|
||||
onAttachmentClick={handleAttachmentClick}
|
||||
/>
|
||||
|
||||
{/* Active Tasks */}
|
||||
{activeSyncs && activeSyncs.length > 0 && (
|
||||
<div className="bg-card rounded-xl border border-primary/30 p-4 md:p-6 shadow-glow">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Active Tasks ({activeSyncs.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{activeSyncs.map((task) => (
|
||||
<ActiveTaskCard key={task.creator_id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Status */}
|
||||
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Service Status</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{services?.map((service) => (
|
||||
<ServiceStatusCard key={service.id} service={service} credential={credentialsByService[service.id]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Failed Downloads */}
|
||||
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">Failed Downloads</h2>
|
||||
{failedDownloads && failedDownloads.length > 0 && (
|
||||
<button
|
||||
onClick={() => handleRetry(failedDownloads.map((f) => f.id))}
|
||||
disabled={retryMutation.isPending}
|
||||
className="btn btn-sm btn-secondary"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-1 ${retryMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
Retry All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!failedDownloads || failedDownloads.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CheckCircle className="w-12 h-12 mx-auto mb-2 text-emerald-500" />
|
||||
<p>No failed downloads</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||
{failedDownloads.map((download) => (
|
||||
<div
|
||||
key={download.id}
|
||||
className="flex items-center justify-between p-3 bg-secondary/50 rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">{download.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{download.extension || 'unknown'} file
|
||||
</p>
|
||||
<p className="text-xs text-red-500 mt-1">{download.error_message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRetry([download.id])}
|
||||
disabled={retryingIds.includes(download.id)}
|
||||
className="ml-2 p-2 rounded-lg hover:bg-secondary transition-colors"
|
||||
title="Retry download"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 text-muted-foreground ${
|
||||
retryingIds.includes(download.id) ? 'animate-spin' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Storage by Creator */}
|
||||
<div className="bg-card rounded-xl border border-border p-4 md:p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Storage by Creator</h2>
|
||||
{!stats?.storage_by_creator || stats.storage_by_creator.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<HardDrive className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No storage data yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||
{stats.storage_by_creator.map((creator, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-500 to-violet-500 p-0.5">
|
||||
<div className="w-full h-full rounded-full overflow-hidden bg-card flex items-center justify-center">
|
||||
{creator.profile_image_url ? (
|
||||
<img src={creator.profile_image_url} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{creator.display_name || creator.username}</p>
|
||||
<p className="text-xs text-muted-foreground">@{creator.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{formatBytes(creator.total_size)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user