874 lines
36 KiB
TypeScript
874 lines
36 KiB
TypeScript
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>
|
|
)
|
|
}
|