Files
media-downloader/web/frontend/src/pages/paid-content/Dashboard.tsx
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/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>
)
}