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

1998 lines
82 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 { FilterBar } from '../../components/FilterPopover'
import {
Users,
User,
Plus,
RefreshCw,
Trash2,
ExternalLink,
CheckCircle,
XCircle,
MoreVertical,
Clock,
Loader2,
Download,
Info,
X,
MapPin,
Calendar,
Link,
FolderOpen,
Pencil,
Search,
ChevronDown,
ChevronUp,
Settings,
Filter,
Shield,
Database,
} from 'lucide-react'
import { api, getErrorMessage, PaidContentCreator, PaidContentActiveTask, PaidContentCreatorGroup, PaidContentCreatorGroupMember, PaidContentTag } from '../../lib/api'
import { formatBytes, formatRelativeTime, formatPlatformName } from '../../lib/utils'
import { notificationManager } from '../../lib/notificationManager'
// Service base URLs - these match the database defaults
const SERVICE_URLS: Record<string, string> = {
coomer: 'https://coomer.party',
kemono: 'https://kemono.party',
youtube: 'https://www.youtube.com',
pornhub: 'https://www.pornhub.com',
xhamster: 'https://xhamster.com',
tiktok: 'https://www.tiktok.com',
instagram: 'https://www.instagram.com',
snapchat: 'https://www.snapchat.com',
hqcelebcorner: 'https://www.hqcelebcorner.net',
picturepub: 'https://picturepub.net',
}
function getServiceUrl(serviceId: string, platform: string, creatorId: string, username?: string): string {
if (serviceId === 'youtube') {
// YouTube channel URLs use @ format for handles or /channel/ for IDs
if (creatorId.startsWith('UC')) {
return `https://www.youtube.com/channel/${creatorId}`
}
return `https://www.youtube.com/@${creatorId}`
}
if (serviceId === 'fansly_direct') {
// Direct Fansly profile URL
return `https://fansly.com/${username || creatorId}`
}
if (serviceId === 'onlyfans_direct') {
return `https://onlyfans.com/${username || creatorId}`
}
if (serviceId === 'pornhub') {
return `https://www.pornhub.com/${creatorId}`
}
if (serviceId === 'xhamster') {
return `https://xhamster.com/${creatorId}`
}
if (serviceId === 'tiktok') {
return `https://www.tiktok.com/@${creatorId}`
}
if (serviceId === 'instagram') {
return `https://www.instagram.com/${creatorId}`
}
if (serviceId === 'snapchat') {
return `https://www.snapchat.com/@${creatorId}`
}
if (serviceId === 'hqcelebcorner') {
return `https://www.hqcelebcorner.net/index.php?search/&q=${encodeURIComponent(creatorId)}&c[title_only]=1&o=date`
}
if (serviceId === 'picturepub') {
return `https://picturepub.net/index.php?search/&q=${encodeURIComponent(creatorId)}&c[title_only]=1&o=date`
}
if (serviceId === 'coppermine') {
return `https://${creatorId}`
}
const baseUrl = SERVICE_URLS[serviceId] || `https://${serviceId}.su`
return `${baseUrl}/${platform}/user/${creatorId}`
}
// Use centralized platform name formatting
function getServiceDisplayName(serviceId: string, platform: string): string {
return formatPlatformName(serviceId) || formatPlatformName(platform)
}
// Domain suffixes that need to be proxied due to CORS/hotlink restrictions
const PROXY_DOMAIN_SUFFIXES = [
'.coomer.st',
'.coomer.party',
'.coomer.su',
'.kemono.st',
'.kemono.party',
'.kemono.su',
'.onlyfans.com',
'.fansly.com',
'.tiktokcdn-us.com',
'.tiktokcdn.com',
'.xhcdn.com',
'.imginn.com',
'.cdninstagram.com',
]
// Get image URL, proxying if needed for CORS-restricted domains
function getProxiedImageUrl(url: string | null | undefined): string | null {
if (!url) return null
try {
const urlObj = new URL(url)
const hostname = urlObj.hostname.toLowerCase()
if (PROXY_DOMAIN_SUFFIXES.some(suffix => hostname.endsWith(suffix))) {
return `/api/paid-content/proxy/image?url=${encodeURIComponent(url)}`
}
return url
} catch {
return url
}
}
interface AddCreatorModalProps {
isOpen: boolean
onClose: () => void
onSuccess: () => void
}
function AddCreatorModal({ isOpen, onClose, onSuccess }: AddCreatorModalProps) {
const [url, setUrl] = useState('')
const [autoDownload, setAutoDownload] = useState(true)
const [downloadEmbeds, setDownloadEmbeds] = useState(true)
const [error, setError] = useState('')
const addMutation = useMutation({
mutationFn: (data: { url: string; auto_download: boolean; download_embeds: boolean }) =>
api.paidContent.addCreatorByUrl(data.url, data.auto_download, data.download_embeds),
onSuccess: () => {
notificationManager.success('Creator Added', 'Creator has been added successfully')
onSuccess()
handleClose()
},
onError: (error: unknown) => {
setError(getErrorMessage(error))
},
})
const handleClose = () => {
setUrl('')
setError('')
onClose()
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setError('')
addMutation.mutate({ url, auto_download: autoDownload, download_embeds: downloadEmbeds })
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card rounded-xl border border-border shadow-lg w-full max-w-md">
<div className="p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">Add Creator</h2>
<p className="text-sm text-muted-foreground mt-1">
Paste a creator URL from any supported platform
</p>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-foreground mb-1">Creator URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://onlyfans.com/username"
className="input w-full"
required
/>
<div className="mt-2 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">Supported platforms:</p>
<div className="flex flex-wrap gap-1.5">
{[
'OnlyFans', 'Fansly', 'YouTube', 'Twitch', 'Instagram',
'TikTok', 'Pornhub', 'XHamster', 'Snapchat', 'Reddit',
'Soundgasm', 'Bellazon', 'HQCelebCorner', 'PicturePub',
'BestEyeCandy', 'Coppermine', 'Coomer.party', 'Kemono.party',
].map(name => (
<span key={name} className="px-1.5 py-0.5 rounded bg-muted text-[11px] text-muted-foreground">{name}</span>
))}
</div>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={autoDownload}
onChange={(e) => setAutoDownload(e.target.checked)}
className="rounded border-border"
/>
<span className="text-sm text-foreground">Auto-download new content</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={downloadEmbeds}
onChange={(e) => setDownloadEmbeds(e.target.checked)}
className="rounded border-border"
/>
<span className="text-sm text-foreground">Download embedded videos (YouTube, etc.)</span>
</label>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg text-sm">
{error}
</div>
)}
<div className="flex items-center justify-end space-x-3 pt-2">
<button type="button" onClick={handleClose} className="btn btn-secondary">
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={addMutation.isPending}>
{addMutation.isPending ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Adding...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Add Creator
</>
)}
</button>
</div>
</form>
</div>
</div>
)
}
// Use shared types from api.ts
type ActiveTask = PaidContentActiveTask
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" />
case 'backfilling':
return <Database className="w-4 h-4 animate-pulse text-purple-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'
case 'backfilling':
return 'Backfilling'
default:
return 'Working'
}
}
const progressValue = task.downloaded || task.progress || 0
const progressPercent = task.total_files
? Math.round((progressValue / task.total_files) * 100)
: 0
return (
<div className="p-4 bg-secondary/50 rounded-lg border border-border">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-lg bg-primary/10">
{getPhaseIcon()}
</div>
<div>
<p className="font-semibold text-foreground">{task.username}</p>
<p className="text-xs text-muted-foreground">
{formatPlatformName(task.platform)} {formatPlatformName(task.service)}
</p>
</div>
</div>
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary/10 text-primary">
{getPhaseLabel()}
</span>
</div>
<p className="text-sm text-foreground mb-2">{task.status}</p>
{(task.phase === 'downloading' || task.phase === 'fetching') && task.total_files && (
<div className="bg-background/50 rounded-lg p-3 space-y-2">
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-medium text-foreground">Progress</span>
<span className="text-xs text-muted-foreground">
{progressValue} / {task.total_files} ({progressPercent}%)
</span>
</div>
<div className="h-2 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-1.5">
<span className="text-xs text-muted-foreground">
Downloading ({task.active_count || task.active_downloads.length}):
</span>
{task.active_downloads.slice(0, 2).map((dl, idx) => (
<div key={idx} className="text-xs">
<div className="flex justify-between items-center mb-0.5">
<span className="text-foreground truncate flex-1 mr-2" title={dl.name}>
{dl.name.length > 30 ? dl.name.slice(0, 30) + '...' : 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 > 2 && (
<span className="text-xs text-muted-foreground">
+{task.active_downloads.length - 2} more...
</span>
)}
</div>
)}
</div>
)}
{task.phase === 'fetching' && !!task.posts_fetched && !task.total_files && (
<div className="bg-background/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{task.posts_fetched}</span> posts fetched
</p>
</div>
)}
</div>
)
}
function CreatorCard({
creator,
onSync,
onDelete,
onToggleEnabled,
onSettings,
isSyncing,
}: {
creator: PaidContentCreator
onSync: () => void
onDelete: () => void
onToggleEnabled: () => void
onSettings?: () => void
isSyncing: boolean
}) {
const [showMenu, setShowMenu] = useState(false)
const [showBio, setShowBio] = useState(false)
const [showLinks, setShowLinks] = useState(false)
// Parse external links if available
let externalLinks: Array<{ title: string; url: string }> = []
try {
externalLinks = creator.external_links ? JSON.parse(creator.external_links) : []
} catch {
externalLinks = []
}
return (
<div className={`bg-card rounded-xl border border-border hover:shadow-md transition-shadow flex flex-col${!creator.enabled ? ' opacity-50' : ''}`}>
{/* Banner - fixed height for uniformity */}
<div className="h-20 bg-gradient-to-br from-pink-500/20 to-violet-500/20 flex-shrink-0 rounded-t-xl overflow-hidden">
{creator.banner_image_url && (
<img
src={getProxiedImageUrl(creator.banner_image_url) || ''}
alt=""
className="w-full h-full object-cover"
/>
)}
</div>
<div className="p-4 -mt-3.5 flex flex-col flex-1">
<div className="flex items-start justify-between">
<div className="flex items-end space-x-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-pink-500 to-violet-500 p-0.5 ring-2 ring-card flex-shrink-0">
<div className="w-full h-full rounded-full overflow-hidden bg-card">
{creator.profile_image_url ? (
<img
src={getProxiedImageUrl(creator.profile_image_url) || ''}
alt={creator.username}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-secondary">
<User className="w-6 h-6 text-muted-foreground" />
</div>
)}
</div>
</div>
<div className="pb-0.5">
<div className="flex items-center gap-1.5">
<h3 className="font-semibold text-foreground">{creator.display_name || creator.username}</h3>
{creator.bio && (
<button
onClick={() => setShowBio(true)}
className="p-0.5 rounded hover:bg-secondary transition-colors"
title="View bio"
>
<Info className="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
)}
{externalLinks.length > 0 && (
<button
onClick={() => setShowLinks(true)}
className="p-0.5 rounded hover:bg-secondary transition-colors"
title="View links"
>
<Link className="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
<p className="text-sm text-muted-foreground">@{creator.username}</p>
{(creator.joined_date || creator.location) && (
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
{creator.joined_date && (
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{creator.joined_date}
</span>
)}
{creator.location && (
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{creator.location}
</span>
)}
</div>
)}
</div>
</div>
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
</button>
{showMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 mt-1 w-48 bg-popover rounded-lg shadow-lg border border-border py-1 z-20">
<button
onClick={() => {
onSync()
setShowMenu(false)
}}
className="w-full flex items-center space-x-2 px-4 py-2 text-sm text-foreground hover:bg-secondary"
>
<RefreshCw className="w-4 h-4" />
<span>Sync Now</span>
</button>
<a
href={getServiceUrl(creator.service_id, creator.platform, creator.creator_id, creator.username)}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center space-x-2 px-4 py-2 text-sm text-foreground hover:bg-secondary"
onClick={() => setShowMenu(false)}
>
<ExternalLink className="w-4 h-4" />
<span>View on {getServiceDisplayName(creator.service_id, creator.platform)}</span>
</a>
<button
onClick={() => {
onToggleEnabled()
setShowMenu(false)
}}
className="w-full flex items-center space-x-2 px-4 py-2 text-sm text-foreground hover:bg-secondary"
>
{creator.enabled ? <XCircle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
<span>{creator.enabled ? 'Disable' : 'Enable'}</span>
</button>
{creator.service_id === 'instagram' && (
<button
onClick={() => { onSettings?.(); setShowMenu(false) }}
className="w-full flex items-center space-x-2 px-4 py-2 text-sm text-foreground hover:bg-secondary"
>
<Settings className="w-4 h-4" />
<span>Sync Settings</span>
</button>
)}
<a
href={`/paid-content/bulk-delete?creator_id=${creator.id}`}
className="w-full flex items-center space-x-2 px-4 py-2 text-sm text-foreground hover:bg-secondary"
onClick={() => setShowMenu(false)}
>
<Trash2 className="w-4 h-4" />
<span>Bulk Delete Posts</span>
</a>
<hr className="my-1 border-border" />
<button
onClick={() => {
onDelete()
setShowMenu(false)
}}
className="w-full flex items-center space-x-2 px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
<span>Remove</span>
</button>
</div>
</>
)}
</div>
</div>
<div className="mt-4 flex items-center space-x-4 text-xs text-muted-foreground">
<span className="inline-flex items-center px-2 py-1 rounded-full bg-secondary">
{formatPlatformName(creator.platform)}
</span>
{!!creator.use_authenticated_api && (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-400" title="Using authenticated Instagram API">
<Shield className="w-3 h-3" />
Authenticated
</span>
)}
{creator.filter_tagged_users && (() => { try { const u = JSON.parse(creator.filter_tagged_users!); return u.length > 0 } catch { return false } })() && (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-400" title={`Filtering by tagged users: ${(() => { try { return JSON.parse(creator.filter_tagged_users!).map((u: string) => '@' + u).join(', ') } catch { return '' } })()}`}>
<Filter className="w-3 h-3" />
Filtered
</span>
)}
<span className={`inline-flex items-center space-x-1 ${creator.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
{creator.enabled ? <CheckCircle className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}
<span>{creator.enabled ? 'Enabled' : 'Disabled'}</span>
</span>
</div>
<div className="mt-4 grid grid-cols-3 gap-3 text-center">
<div className="p-2 rounded-lg bg-secondary/50">
<p className="text-lg font-semibold text-foreground">{creator.post_count || 0}</p>
<p className="text-xs text-muted-foreground">Posts</p>
</div>
<div className="p-2 rounded-lg bg-secondary/50">
<p className="text-lg font-semibold text-foreground">{creator.downloaded_count || 0}</p>
<p className="text-xs text-muted-foreground">Downloaded</p>
</div>
<div className="p-2 rounded-lg bg-secondary/50">
<p className="text-lg font-semibold text-foreground">{formatBytes(creator.total_size_bytes || 0)}</p>
<p className="text-xs text-muted-foreground">Size</p>
</div>
</div>
<div className="mt-auto pt-4 flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center space-x-1">
<Clock className="w-3 h-3" />
<span>Last checked: {creator.last_checked ? formatRelativeTime(creator.last_checked) : 'Never'}</span>
</span>
{isSyncing && (
<span className="flex items-center space-x-1 text-primary">
<RefreshCw className="w-3 h-3 animate-spin" />
<span>Syncing...</span>
</span>
)}
</div>
</div>
{/* Bio Popup Modal */}
{showBio && creator.bio && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={() => setShowBio(false)}
>
<div
className="bg-card rounded-xl border border-border shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-secondary/50">
<div className="flex items-center gap-3">
{creator.profile_image_url ? (
<img
src={getProxiedImageUrl(creator.profile_image_url) || ''}
alt={creator.username}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-violet-500 flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
)}
<div>
<h3 className="font-semibold text-foreground">{creator.display_name || creator.username}</h3>
<p className="text-xs text-muted-foreground">@{creator.username}</p>
</div>
</div>
<button
onClick={() => setShowBio(false)}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Bio Content */}
<div className="p-4 overflow-y-auto max-h-[60vh]">
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{creator.bio}</p>
</div>
</div>
</div>
)}
{/* External Links Popup Modal */}
{showLinks && externalLinks.length > 0 && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
onClick={() => setShowLinks(false)}
>
<div
className="bg-card rounded-xl border border-border shadow-xl max-w-md w-full max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-secondary/50">
<div className="flex items-center gap-3">
{creator.profile_image_url ? (
<img
src={getProxiedImageUrl(creator.profile_image_url) || ''}
alt={creator.username}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-violet-500 flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
)}
<div>
<h3 className="font-semibold text-foreground">{creator.display_name || creator.username}</h3>
<p className="text-xs text-muted-foreground">External Links</p>
</div>
</div>
<button
onClick={() => setShowLinks(false)}
className="p-2 rounded-lg hover:bg-secondary transition-colors"
>
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* Links Content */}
<div className="p-4 overflow-y-auto max-h-[60vh]">
<div className="space-y-2">
{externalLinks.map((link: { title: string; url: string }, index: number) => (
<a
key={index}
href={link.url.startsWith('http') ? link.url : `https://${link.url}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors group"
>
<ExternalLink className="w-4 h-4 text-muted-foreground group-hover:text-foreground" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm text-foreground">{link.title}</p>
<p className="text-xs text-muted-foreground truncate">{link.url}</p>
</div>
</a>
))}
</div>
</div>
</div>
</div>
)}
</div>
)
}
function CreatorSettingsModal({
creator,
onClose,
}: {
creator: PaidContentCreator
onClose: () => void
}) {
const queryClient = useQueryClient()
const [syncPosts, setSyncPosts] = useState(creator.sync_posts !== 0)
const [syncStories, setSyncStories] = useState(creator.sync_stories !== 0)
const [syncHighlights, setSyncHighlights] = useState(creator.sync_highlights !== 0)
const [useAuthenticatedApi, setUseAuthenticatedApi] = useState(creator.use_authenticated_api !== 0)
const [selectedUsers, setSelectedUsers] = useState<string[]>(() => {
try {
return creator.filter_tagged_users ? JSON.parse(creator.filter_tagged_users) : []
} catch { return [] }
})
const { data: taggedUsers = [] } = useQuery({
queryKey: ['paid-content-creator-tagged-users', creator.id],
queryFn: () => api.paidContent.getCreatorTaggedUsers(creator.id),
})
const saveMutation = useMutation({
mutationFn: () => api.paidContent.updateCreator(creator.id, {
sync_posts: syncPosts,
sync_stories: syncStories,
sync_highlights: syncHighlights,
use_authenticated_api: useAuthenticatedApi,
filter_tagged_users: selectedUsers,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-creators'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-creators-all'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
notificationManager.success('Settings Saved', 'Sync settings saved successfully')
onClose()
},
onError: (error) => {
notificationManager.error('Save Failed', 'Failed to save settings: ' + getErrorMessage(error))
},
})
const toggleUser = (username: string) => {
setSelectedUsers(prev =>
prev.includes(username) ? prev.filter(u => u !== username) : [...prev, username]
)
}
return (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card rounded-xl border border-border shadow-lg w-full max-w-md">
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold text-foreground">Sync Settings @{creator.username}</h3>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-secondary">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-4 space-y-5">
{/* Content Types */}
<div>
<h4 className="text-sm font-medium text-foreground mb-3">Content Types</h4>
<div className="space-y-2">
{[
{ label: 'Sync Posts', value: syncPosts, setter: setSyncPosts },
{ label: 'Sync Stories', value: syncStories, setter: setSyncStories },
{ label: 'Sync Highlights', value: syncHighlights, setter: setSyncHighlights },
].map(({ label, value, setter }) => (
<div key={label} className="flex items-center justify-between py-1.5">
<span className="text-sm text-foreground">{label}</span>
<button
type="button"
role="switch"
aria-checked={value}
onClick={() => setter(!value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-blue-500' : 'bg-slate-300 dark:bg-slate-600'}`}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[2px]'}`} />
</button>
</div>
))}
</div>
</div>
{/* Authenticated API (Instagram only) */}
{creator.platform === 'instagram' && (
<div>
<h4 className="text-sm font-medium text-foreground mb-3">Authenticated API</h4>
<div className="flex items-center justify-between py-1.5">
<div>
<span className="text-sm text-foreground">Use Authenticated API</span>
<p className="text-xs text-muted-foreground">Fetches posts using browser cookies as the primary method. Falls back to unauthenticated if cookies expire.</p>
</div>
<button
type="button"
role="switch"
aria-checked={useAuthenticatedApi}
onClick={() => setUseAuthenticatedApi(!useAuthenticatedApi)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${useAuthenticatedApi ? 'bg-blue-500' : 'bg-slate-300 dark:bg-slate-600'}`}
>
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${useAuthenticatedApi ? 'translate-x-[18px]' : 'translate-x-[2px]'}`} />
</button>
</div>
</div>
)}
{/* Backfill Missing Posts (Instagram only, when authenticated API is enabled) */}
{creator.platform === 'instagram' && useAuthenticatedApi && (
<div>
<h4 className="text-sm font-medium text-foreground mb-3">Backfill Missing Posts</h4>
<p className="text-xs text-muted-foreground mb-3">Scans the full timeline using browser cookies to find and import any posts missing from the database. Automatically downloads new media afterward.</p>
<button
onClick={() => {
api.paidContent.syncCreator(creator.id, true, true)
notificationManager.success('Backfill Started', `Backfilling @${creator.username} — check the task card for progress`)
onClose()
}}
className="px-3 py-1.5 text-sm rounded-lg bg-purple-500 text-white hover:bg-purple-600 flex items-center gap-2"
>
<Database className="w-3.5 h-3.5" />
Backfill Now
</button>
</div>
)}
{/* Tagged User Filter */}
<div>
<h4 className="text-sm font-medium text-foreground mb-1">Only show posts tagged with</h4>
<p className="text-xs text-muted-foreground mb-3">When set, only posts where selected users are tagged will appear in the feed</p>
{taggedUsers.length > 0 ? (
<div className="max-h-[200px] overflow-y-auto space-y-0.5 border border-border rounded-lg p-2">
{taggedUsers.map(({ username, post_count }) => (
<label key={username} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary cursor-pointer">
<input
type="checkbox"
checked={selectedUsers.includes(username)}
onChange={() => toggleUser(username)}
className="w-4 h-4 rounded text-blue-500 border-slate-300 focus:ring-blue-500"
/>
<span className="text-sm text-foreground">@{username}</span>
<span className="text-xs text-muted-foreground ml-auto">({post_count} posts)</span>
</label>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">No tagged users found for this creator</p>
)}
</div>
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg border border-border hover:bg-secondary">
Cancel
</button>
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="px-4 py-2 text-sm rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)
}
// Service to platforms mapping
const SERVICE_PLATFORMS: Record<string, string[]> = {
coomer: ['onlyfans', 'fansly', 'candfans'],
kemono: ['patreon', 'fanbox', 'gumroad', 'subscribestar', 'discord'],
fansly_direct: ['fansly'],
onlyfans_direct: ['onlyfans'],
youtube: ['youtube'],
pornhub: ['pornhub'],
snapchat: ['snapchat'],
hqcelebcorner: ['hqcelebcorner'],
picturepub: ['picturepub'],
}
// ============================================================================
// Member Filter Modal
// ============================================================================
function MemberFilterModal({
member,
groupId,
onClose,
}: {
member: PaidContentCreatorGroupMember
groupId: number
onClose: () => void
}) {
const queryClient = useQueryClient()
const [selectedUsers, setSelectedUsers] = useState<string[]>(() => {
try {
return member.filter_tagged_users ? JSON.parse(member.filter_tagged_users) : []
} catch { return [] }
})
const [selectedTagIds, setSelectedTagIds] = useState<number[]>(() => {
try {
return member.filter_tag_ids ? JSON.parse(member.filter_tag_ids) : []
} catch { return [] }
})
const { data: taggedUsers = [] } = useQuery({
queryKey: ['paid-content-creator-tagged-users', member.id],
queryFn: () => api.paidContent.getCreatorTaggedUsers(member.id),
})
const { data: tags = [] } = useQuery<PaidContentTag[]>({
queryKey: ['paid-content-tags', member.id],
queryFn: () => api.paidContent.getTags([member.id]),
})
const saveMutation = useMutation({
mutationFn: () => api.paidContent.updateGroupMemberFilters(groupId, member.id, {
filter_tagged_users: selectedUsers.length > 0 ? selectedUsers : null,
filter_tag_ids: selectedTagIds.length > 0 ? selectedTagIds : null,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-creator-group', groupId] })
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
notificationManager.success('Filters Saved', `Filters updated for @${member.username}`)
onClose()
},
onError: (error) => {
notificationManager.error('Save Failed', 'Failed to save filters: ' + getErrorMessage(error))
},
})
const toggleUser = (username: string) => {
setSelectedUsers(prev =>
prev.includes(username) ? prev.filter(u => u !== username) : [...prev, username]
)
}
const toggleTag = (tagId: number) => {
setSelectedTagIds(prev =>
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
)
}
const hasFilters = selectedUsers.length > 0 || selectedTagIds.length > 0
return (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card rounded-xl border border-border shadow-lg w-full max-w-md">
<div className="flex items-center justify-between p-4 border-b border-border">
<h3 className="font-semibold text-foreground">Feed Filters @{member.username}</h3>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-secondary">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-4 space-y-5">
<p className="text-xs text-muted-foreground">
When filters are set, only posts matching at least one filter will appear in the group feed for this creator.
</p>
{/* Tagged Users */}
<div>
<h4 className="text-sm font-medium text-foreground mb-1">Only show posts tagged with</h4>
{taggedUsers.length > 0 ? (
<div className="max-h-[160px] overflow-y-auto space-y-0.5 border border-border rounded-lg p-2">
{taggedUsers.map(({ username, post_count }: { username: string; post_count: number }) => (
<label key={username} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary cursor-pointer">
<input
type="checkbox"
checked={selectedUsers.includes(username)}
onChange={() => toggleUser(username)}
className="w-4 h-4 rounded text-blue-500 border-slate-300 focus:ring-blue-500"
/>
<span className="text-sm text-foreground">@{username}</span>
<span className="text-xs text-muted-foreground ml-auto">({post_count})</span>
</label>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">No tagged users found</p>
)}
</div>
{/* Tags */}
<div>
<h4 className="text-sm font-medium text-foreground mb-1">Only show posts with tags</h4>
{tags.length > 0 ? (
<div className="max-h-[160px] overflow-y-auto space-y-0.5 border border-border rounded-lg p-2">
{tags.map((tag) => (
<label key={tag.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary cursor-pointer">
<input
type="checkbox"
checked={selectedTagIds.includes(tag.id)}
onChange={() => toggleTag(tag.id)}
className="w-4 h-4 rounded text-blue-500 border-slate-300 focus:ring-blue-500"
/>
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color || '#6b7280' }}
/>
<span className="text-sm text-foreground">{tag.name}</span>
{tag.post_count != null && (
<span className="text-xs text-muted-foreground ml-auto">({tag.post_count})</span>
)}
</label>
))}
</div>
) : (
<p className="text-xs text-muted-foreground italic">No tags found</p>
)}
</div>
{hasFilters && (
<button
onClick={() => { setSelectedUsers([]); setSelectedTagIds([]) }}
className="text-xs text-blue-500 hover:text-blue-600"
>
Clear all filters
</button>
)}
</div>
<div className="p-4 border-t border-border flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg border border-border hover:bg-secondary">
Cancel
</button>
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="px-4 py-2 text-sm rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
)
}
// ============================================================================
// Creator Groups Management Component
// ============================================================================
function CreatorGroupsManagement() {
const queryClient = useQueryClient()
const [showCreateForm, setShowCreateForm] = useState(false)
const [newGroupName, setNewGroupName] = useState('')
const [newGroupDesc, setNewGroupDesc] = useState('')
const [expandedGroupId, setExpandedGroupId] = useState<number | null>(null)
const [editingGroupId, setEditingGroupId] = useState<number | null>(null)
const [editName, setEditName] = useState('')
const [editDesc, setEditDesc] = useState('')
const [addCreatorSearch, setAddCreatorSearch] = useState('')
const [showAddCreatorFor, setShowAddCreatorFor] = useState<number | null>(null)
const [editingMemberFilters, setEditingMemberFilters] = useState<PaidContentCreatorGroupMember | null>(null)
const { data: groups = [], isLoading } = useQuery({
queryKey: ['paid-content-creator-groups'],
queryFn: () => api.paidContent.getCreatorGroups(),
})
const { data: expandedGroup } = useQuery({
queryKey: ['paid-content-creator-group', expandedGroupId],
queryFn: () => api.paidContent.getCreatorGroup(expandedGroupId!),
enabled: !!expandedGroupId,
})
const { data: allCreators = [] } = useQuery<PaidContentCreator[]>({
queryKey: ['paid-content-creators-all'],
queryFn: () => api.paidContent.getCreators({}),
staleTime: 30000,
})
const createMutation = useMutation({
mutationFn: () => api.paidContent.createCreatorGroup(newGroupName, newGroupDesc || undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-creator-groups'] })
setNewGroupName('')
setNewGroupDesc('')
setShowCreateForm(false)
notificationManager.success('Group Created', 'Creator group has been created')
},
onError: (error: unknown) => notificationManager.error('Error', getErrorMessage(error)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: { name?: string; description?: string } }) =>
api.paidContent.updateCreatorGroup(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-creator-groups'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-creator-group'] })
setEditingGroupId(null)
notificationManager.success('Group Updated', 'Creator group has been updated')
},
onError: (error: unknown) => notificationManager.error('Error', getErrorMessage(error)),
})
const deleteMutation = useMutation({
mutationFn: (id: number) => api.paidContent.deleteCreatorGroup(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-creator-groups'] })
if (expandedGroupId) setExpandedGroupId(null)
notificationManager.success('Group Deleted', 'Creator group has been deleted')
},
onError: (error: unknown) => notificationManager.error('Error', getErrorMessage(error)),
})
const addMemberMutation = useMutation({
mutationFn: ({ groupId, creatorId }: { groupId: number; creatorId: number }) =>
api.paidContent.addCreatorToGroup(groupId, creatorId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-creator-groups'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-creator-group'] })
},
onError: (error: unknown) => notificationManager.error('Error', getErrorMessage(error)),
})
const removeMemberMutation = useMutation({
mutationFn: ({ groupId, creatorId }: { groupId: number; creatorId: number }) =>
api.paidContent.removeCreatorFromGroup(groupId, creatorId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-creator-groups'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-creator-group'] })
},
onError: (error: unknown) => notificationManager.error('Error', getErrorMessage(error)),
})
const memberIds = new Set(expandedGroup?.members?.map((m: PaidContentCreatorGroupMember) => m.id) || [])
const filteredAddCreators = allCreators.filter(c => {
if (memberIds.has(c.id)) return false
if (!addCreatorSearch) return true
const q = addCreatorSearch.toLowerCase()
return c.username.toLowerCase().includes(q) || (c.display_name?.toLowerCase().includes(q))
})
return (
<div className="space-y-4">
{/* Create Group */}
{!showCreateForm ? (
<button
onClick={() => setShowCreateForm(true)}
className="btn btn-primary"
>
<Plus className="w-4 h-4 mr-2" />
Create Group
</button>
) : (
<div className="bg-card rounded-xl border border-border p-4 space-y-3">
<h3 className="font-semibold text-foreground">New Group</h3>
<input
type="text"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
placeholder="Group name"
className="input w-full"
autoFocus
/>
<input
type="text"
value={newGroupDesc}
onChange={e => setNewGroupDesc(e.target.value)}
placeholder="Description (optional)"
className="input w-full"
/>
<div className="flex items-center gap-2">
<button
onClick={() => createMutation.mutate()}
disabled={!newGroupName.trim() || createMutation.isPending}
className="btn btn-primary"
>
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
</button>
<button onClick={() => setShowCreateForm(false)} className="btn btn-secondary">
Cancel
</button>
</div>
</div>
)}
{/* Groups List */}
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : groups.length === 0 ? (
<div className="bg-card rounded-xl border border-border p-8 text-center">
<FolderOpen className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold text-foreground">No groups yet</h3>
<p className="text-muted-foreground mt-1">Create a group to organize your creators</p>
</div>
) : (
<div className="space-y-3">
{groups.map((group: PaidContentCreatorGroup) => {
const isExpanded = expandedGroupId === group.id
const isEditing = editingGroupId === group.id
return (
<div key={group.id} className="bg-card rounded-xl border border-border overflow-hidden">
{/* Group Header */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => {
if (isExpanded) {
setExpandedGroupId(null)
setShowAddCreatorFor(null)
} else {
setExpandedGroupId(group.id)
}
}}
>
<div className="flex items-center gap-3 min-w-0">
<FolderOpen className="w-5 h-5 text-violet-500 shrink-0" />
<div className="min-w-0">
{isEditing ? (
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<input
type="text"
value={editName}
onChange={e => setEditName(e.target.value)}
className="input text-sm"
autoFocus
/>
<input
type="text"
value={editDesc}
onChange={e => setEditDesc(e.target.value)}
placeholder="Description"
className="input text-sm"
/>
<button
onClick={() => updateMutation.mutate({ id: group.id, data: { name: editName, description: editDesc || undefined } })}
className="btn btn-primary btn-sm"
disabled={!editName.trim()}
>
Save
</button>
<button onClick={() => setEditingGroupId(null)} className="btn btn-secondary btn-sm">
Cancel
</button>
</div>
) : (
<>
<h3 className="font-semibold text-foreground truncate">{group.name}</h3>
{group.description && (
<p className="text-sm text-muted-foreground truncate">{group.description}</p>
)}
</>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm text-muted-foreground">
{group.member_count} {group.member_count === 1 ? 'creator' : 'creators'}
</span>
{!isEditing && (
<>
<button
onClick={e => {
e.stopPropagation()
setEditingGroupId(group.id)
setEditName(group.name)
setEditDesc(group.description || '')
}}
className="p-1.5 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground"
title="Edit group"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={e => {
e.stopPropagation()
if (window.confirm(`Delete group "${group.name}"? This won't delete the creators themselves.`)) {
deleteMutation.mutate(group.id)
}
}}
className="p-1.5 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/20 text-muted-foreground hover:text-red-600"
title="Delete group"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
{isExpanded ? <ChevronUp className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</div>
</div>
{/* Expanded Members */}
{isExpanded && expandedGroup && (
<div className="border-t border-border p-4 space-y-3">
{/* Members */}
{expandedGroup.members && expandedGroup.members.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{expandedGroup.members.map((member: PaidContentCreatorGroupMember) => {
const hasActiveFilters = (member.filter_tagged_users && member.filter_tagged_users !== '[]') || (member.filter_tag_ids && member.filter_tag_ids !== '[]')
return (
<div key={member.id} className="flex items-center gap-2 p-2 rounded-lg bg-accent/30">
{member.profile_image_url ? (
<img
src={getProxiedImageUrl(member.profile_image_url) || ''}
alt={member.username}
className="w-8 h-8 rounded-full object-cover"
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : (
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground truncate">{member.display_name || member.username}</div>
<div className="text-xs text-muted-foreground">{formatPlatformName(member.platform)}</div>
</div>
<button
onClick={() => setEditingMemberFilters(member)}
className={`p-1 rounded hover:bg-accent shrink-0 ${hasActiveFilters ? 'text-blue-500' : 'text-muted-foreground hover:text-foreground'}`}
title={hasActiveFilters ? 'Edit feed filters (active)' : 'Set feed filters'}
>
<Filter className="w-3.5 h-3.5" />
</button>
<button
onClick={() => removeMemberMutation.mutate({ groupId: group.id, creatorId: member.id })}
className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/20 text-muted-foreground hover:text-red-600 shrink-0"
title="Remove from group"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No members yet. Add creators to this group.</p>
)}
{/* Add Creator */}
{showAddCreatorFor === group.id ? (
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={addCreatorSearch}
onChange={e => setAddCreatorSearch(e.target.value)}
placeholder="Search creators to add..."
className="input w-full pl-9"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto space-y-1 border border-border rounded-lg p-1">
{filteredAddCreators.length === 0 ? (
<p className="text-sm text-muted-foreground p-2">No creators found</p>
) : (
filteredAddCreators.slice(0, 50).map(creator => (
<button
key={creator.id}
onClick={() => addMemberMutation.mutate({ groupId: group.id, creatorId: creator.id })}
className="w-full flex items-center gap-2 p-2 rounded-lg hover:bg-accent text-left"
>
{creator.profile_image_url ? (
<img
src={getProxiedImageUrl(creator.profile_image_url) || ''}
alt={creator.username}
className="w-7 h-7 rounded-full object-cover"
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : (
<div className="w-7 h-7 rounded-full bg-accent flex items-center justify-center">
<User className="w-3.5 h-3.5 text-muted-foreground" />
</div>
)}
<span className="text-sm text-foreground truncate">{creator.display_name || creator.username}</span>
<span className="text-xs text-muted-foreground ml-auto shrink-0">{formatPlatformName(creator.platform)}</span>
</button>
))
)}
</div>
<button
onClick={() => { setShowAddCreatorFor(null); setAddCreatorSearch('') }}
className="btn btn-secondary btn-sm"
>
Done
</button>
</div>
) : (
<button
onClick={() => setShowAddCreatorFor(group.id)}
className="btn btn-secondary btn-sm"
>
<Plus className="w-3.5 h-3.5 mr-1" />
Add Creator
</button>
)}
</div>
)}
</div>
)
})}
</div>
)}
{editingMemberFilters && expandedGroupId && (
<MemberFilterModal
member={editingMemberFilters}
groupId={expandedGroupId}
onClose={() => setEditingMemberFilters(null)}
/>
)}
</div>
)
}
function serviceSort(a: string, b: string) {
return formatPlatformName(a).localeCompare(formatPlatformName(b))
}
function ServiceGroupedCreators({
creators,
syncingIds,
onSync,
onDelete,
onToggleEnabled,
onSettings,
onSyncService,
syncingServices,
}: {
creators: PaidContentCreator[]
syncingIds: number[]
onSync: (id: number) => void
onDelete: (id: number, username: string) => void
onToggleEnabled: (id: number, enabled: boolean) => void
onSettings: (creator: PaidContentCreator) => void
onSyncService: (serviceId: string) => void
syncingServices: Set<string>
}) {
const [collapsedServices, setCollapsedServices] = useState<Set<string>>(new Set())
// Group creators by service_id
const grouped = creators.reduce<Record<string, PaidContentCreator[]>>((acc, creator) => {
const key = creator.service_id
if (!acc[key]) acc[key] = []
acc[key].push(creator)
return acc
}, {})
// Sort creators within each group by username
for (const key of Object.keys(grouped)) {
grouped[key].sort((a, b) => (a.username || '').localeCompare(b.username || ''))
}
const serviceIds = Object.keys(grouped).sort(serviceSort)
const toggleService = (serviceId: string) => {
setCollapsedServices(prev => {
const next = new Set(prev)
if (next.has(serviceId)) {
next.delete(serviceId)
} else {
next.add(serviceId)
}
return next
})
}
// If only one service, show a simple header with sync button but no collapsible
if (serviceIds.length <= 1 && serviceIds.length === 1) {
const serviceId = serviceIds[0]
const isSyncingService = syncingServices.has(serviceId) || creators.some(c => syncingIds.includes(c.id))
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-foreground">
{formatPlatformName(serviceId)}
</h3>
<span className="text-sm text-muted-foreground">
{creators.length} {creators.length === 1 ? 'creator' : 'creators'}
</span>
</div>
<button
onClick={() => onSyncService(serviceId)}
disabled={isSyncingService}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={`Sync all ${formatPlatformName(serviceId)} creators`}
>
<RefreshCw className={`w-3.5 h-3.5 ${isSyncingService ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Sync All</span>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{creators.map((creator) => (
<CreatorCard
key={creator.id}
creator={creator}
onSync={() => onSync(creator.id)}
onDelete={() => onDelete(creator.id, creator.username)}
onToggleEnabled={() => onToggleEnabled(creator.id, !creator.enabled)}
onSettings={() => onSettings(creator)}
isSyncing={syncingIds.includes(creator.id)}
/>
))}
</div>
</div>
)
}
// No creators
if (serviceIds.length === 0) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{creators.map((creator) => (
<CreatorCard
key={creator.id}
creator={creator}
onSync={() => onSync(creator.id)}
onDelete={() => onDelete(creator.id, creator.username)}
onToggleEnabled={() => onToggleEnabled(creator.id, !creator.enabled)}
onSettings={() => onSettings(creator)}
isSyncing={syncingIds.includes(creator.id)}
/>
))}
</div>
)
}
return (
<div className="space-y-4">
{serviceIds.map(serviceId => {
const serviceCreators = grouped[serviceId]
const isCollapsed = collapsedServices.has(serviceId)
return (
<div key={serviceId} className="bg-card rounded-xl border border-border overflow-hidden">
{/* Service Header */}
<button
onClick={() => toggleService(serviceId)}
className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3">
<h3 className="font-semibold text-foreground">
{formatPlatformName(serviceId)}
</h3>
<span className="text-sm text-muted-foreground">
{serviceCreators.length} {serviceCreators.length === 1 ? 'creator' : 'creators'}
</span>
</div>
<div className="flex items-center gap-2">
{(() => {
const isSyncingService = syncingServices.has(serviceId) || serviceCreators.some(c => syncingIds.includes(c.id))
return (
<button
onClick={(e) => {
e.stopPropagation()
onSyncService(serviceId)
}}
disabled={isSyncingService}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={`Sync all ${formatPlatformName(serviceId)} creators`}
>
<RefreshCw className={`w-3.5 h-3.5 ${isSyncingService ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Sync All</span>
</button>
)
})()}
{isCollapsed
? <ChevronDown className="w-5 h-5 text-muted-foreground" />
: <ChevronUp className="w-5 h-5 text-muted-foreground" />
}
</div>
</button>
{/* Creator Grid */}
{!isCollapsed && (
<div className="px-4 pb-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{serviceCreators.map((creator) => (
<CreatorCard
key={creator.id}
creator={creator}
onSync={() => onSync(creator.id)}
onDelete={() => onDelete(creator.id, creator.username)}
onToggleEnabled={() => onToggleEnabled(creator.id, !creator.enabled)}
onSettings={() => onSettings(creator)}
isSyncing={syncingIds.includes(creator.id)}
/>
))}
</div>
)}
</div>
)
})}
</div>
)
}
type CreatorsTabType = 'creators' | 'groups'
export default function PaidContentCreators() {
useBreadcrumb(breadcrumbConfig['/paid-content/creators'])
const queryClient = useQueryClient()
const [showAddModal, setShowAddModal] = useState(false)
const [search, setSearch] = useState('')
const [filterPlatform, setFilterPlatform] = useState('')
const [filterService, setFilterService] = useState('')
const [syncingIds, setSyncingIds] = useState<number[]>([])
const [activeTab, setActiveTab] = useState<CreatorsTabType>('creators')
const [settingsCreator, setSettingsCreator] = useState<PaidContentCreator | null>(null)
const tabs = [
{ id: 'creators' as CreatorsTabType, label: 'Creators', icon: Users },
{ id: 'groups' as CreatorsTabType, label: 'Groups', icon: FolderOpen },
]
// Poll for active sync tasks
const { data: activeSyncs } = useQuery({
queryKey: ['paid-content-active-syncs'],
queryFn: () => api.paidContent.getActiveSyncs(),
refetchInterval: 2000,
staleTime: 1000,
})
// Refetch creators more frequently when syncs are active
const hasActiveSyncs = activeSyncs && activeSyncs.length > 0
// Fetch all creators to derive filter options
const { data: allCreators = [] } = useQuery<PaidContentCreator[]>({
queryKey: ['paid-content-creators-all'],
queryFn: () => api.paidContent.getCreators({}),
staleTime: 30000, // Cache for 30s
})
// Derive unique services and platforms from actual creators
const availableServices = [...new Set(allCreators.map(c => c.service_id))].sort()
const allPlatforms = [...new Set(allCreators.map(c => c.platform))].sort()
// Filter platforms based on selected service
const availablePlatforms = filterService
? allPlatforms.filter(p => SERVICE_PLATFORMS[filterService]?.includes(p))
: allPlatforms
// Clear platform if it's not valid for the selected service
const handleServiceChange = (service: string) => {
setFilterService(service)
if (service && filterPlatform) {
const validPlatforms = SERVICE_PLATFORMS[service] || []
if (!validPlatforms.includes(filterPlatform)) {
setFilterPlatform('')
}
}
}
const { data: creators, isLoading } = useQuery<PaidContentCreator[]>({
queryKey: ['paid-content-creators', search, filterPlatform, filterService],
queryFn: () =>
api.paidContent.getCreators({
search: search || undefined,
platform: filterPlatform || undefined,
service: filterService || undefined,
}),
refetchInterval: hasActiveSyncs ? 3000 : false, // Poll every 3s during active syncs
})
const syncMutation = useMutation({
mutationFn: (id: number) => {
setSyncingIds((prev) => [...prev, id])
return api.paidContent.syncCreator(id)
},
onSuccess: () => {
notificationManager.success('Sync Started', 'Creator sync has been queued')
},
onError: (error: unknown) => {
notificationManager.error('Sync Failed', getErrorMessage(error))
},
onSettled: (_result, _error, id) => {
setSyncingIds((prev) => prev.filter((i) => i !== id))
queryClient.invalidateQueries({ queryKey: ['paid-content-creators'] })
},
})
const [syncingServices, setSyncingServices] = useState<Set<string>>(new Set())
const syncServiceMutation = useMutation({
mutationFn: (serviceId: string) => {
setSyncingServices(prev => new Set(prev).add(serviceId))
return api.paidContent.syncService(serviceId)
},
onSuccess: (_data, serviceId) => {
const count = _data?.count ?? 0
notificationManager.success('Service Sync Started', `Queued sync for ${count} ${formatPlatformName(serviceId)} creators`)
},
onError: (error: unknown) => {
notificationManager.error('Service Sync Failed', getErrorMessage(error))
},
onSettled: (_result, _error, serviceId) => {
setSyncingServices(prev => {
const next = new Set(prev)
next.delete(serviceId)
return next
})
queryClient.invalidateQueries({ queryKey: ['paid-content-creators'] })
},
})
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
api.paidContent.updateCreator(id, { enabled }),
onSuccess: (_data, variables) => {
notificationManager.success(
variables.enabled ? 'Creator Enabled' : 'Creator Disabled',
variables.enabled ? 'Creator will be included in syncs' : 'Creator will be skipped during syncs'
)
queryClient.invalidateQueries({ queryKey: ['paid-content-creators'] })
},
onError: (error: unknown) => {
notificationManager.error('Update Failed', getErrorMessage(error))
},
})
const [deleteTarget, setDeleteTarget] = useState<{ id: number; username: string } | null>(null)
const [deleteFiles, setDeleteFiles] = useState(false)
const deleteMutation = useMutation({
mutationFn: ({ id, deleteFiles: df }: { id: number; deleteFiles: boolean }) =>
api.paidContent.deleteCreator(id, df),
onSuccess: (_data, variables) => {
notificationManager.success(
'Creator Removed',
variables.deleteFiles
? 'Creator and downloaded files have been removed'
: 'Creator has been removed'
)
queryClient.invalidateQueries({ queryKey: ['paid-content-creators'] })
setDeleteTarget(null)
setDeleteFiles(false)
},
onError: (error: unknown) => {
notificationManager.error('Delete Failed', getErrorMessage(error))
},
})
const handleDelete = (id: number, username: string) => {
setDeleteTarget({ id, username })
setDeleteFiles(false)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<Users className="w-8 h-8 text-violet-500" />
Creators
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1">
Manage tracked creators and groups
</p>
</div>
{activeTab === 'creators' && (
<button onClick={() => setShowAddModal(true)} className="btn btn-primary">
<Plus className="w-4 h-4 mr-2" />
Add Creator
</button>
)}
</div>
{/* Tab Navigation */}
<div className="border-b border-border">
<nav className="flex space-x-4">
{tabs.map(tab => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-violet-500 text-violet-600 dark:text-violet-400'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
)
})}
</nav>
</div>
{/* Groups Tab */}
{activeTab === 'groups' && <CreatorGroupsManagement />}
{/* Creators Tab */}
{activeTab === 'creators' && <>
{/* Filters */}
<FilterBar
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="Search creators..."
filterSections={[
{
id: 'service',
label: 'Service',
type: 'select',
options: [
{ value: '', label: 'All Services' },
...availableServices.map(service => ({
value: service,
label: formatPlatformName(service),
})),
],
value: filterService,
onChange: (value) => handleServiceChange(value as string),
},
{
id: 'platform',
label: 'Platform',
type: 'select',
options: [
{ value: '', label: 'All Platforms' },
...availablePlatforms.map(platform => ({
value: platform,
label: formatPlatformName(platform),
})),
],
value: filterPlatform,
onChange: (value) => setFilterPlatform(value as string),
},
]}
activeFilters={[
...(search ? [{
id: 'search',
label: 'Search',
value: search,
displayValue: `"${search}"`,
onRemove: () => setSearch(''),
}] : []),
...(filterService ? [{
id: 'service',
label: 'Service',
value: filterService,
displayValue: formatPlatformName(filterService),
onRemove: () => setFilterService(''),
}] : []),
...(filterPlatform ? [{
id: 'platform',
label: 'Platform',
value: filterPlatform,
displayValue: formatPlatformName(filterPlatform),
onRemove: () => setFilterPlatform(''),
}] : []),
]}
onClearAll={() => {
setSearch('')
setFilterService('')
setFilterPlatform('')
}}
totalCount={creators?.length}
countLabel="creators"
/>
{/* Active Tasks */}
{activeSyncs && activeSyncs.length > 0 && (
<div className="bg-card rounded-xl border border-primary/30 p-4 shadow-glow">
<div className="flex items-center space-x-2 mb-3">
<Loader2 className="w-4 h-4 text-primary animate-spin" />
<h2 className="font-semibold text-foreground">
Active Tasks ({activeSyncs.length})
</h2>
</div>
<div className="space-y-3">
{activeSyncs.map((task) => (
<ActiveTaskCard key={task.creator_id} task={task} />
))}
</div>
</div>
)}
{/* Creator Grid */}
{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>
) : !creators || creators.length === 0 ? (
<div className="bg-card rounded-xl border border-border p-12 text-center">
<Users className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-semibold text-foreground">No creators found</h3>
<p className="text-muted-foreground mt-1">
{search || filterPlatform || filterService
? 'Try adjusting your filters'
: 'Add a creator to get started'}
</p>
{!search && !filterPlatform && !filterService && (
<button onClick={() => setShowAddModal(true)} className="btn btn-primary mt-4">
<Plus className="w-4 h-4 mr-2" />
Add Creator
</button>
)}
</div>
) : (
<ServiceGroupedCreators
creators={creators}
syncingIds={syncingIds}
onSync={(id) => syncMutation.mutate(id)}
onDelete={(id, username) => handleDelete(id, username)}
onToggleEnabled={(id, enabled) => toggleMutation.mutate({ id, enabled })}
onSettings={(creator) => setSettingsCreator(creator)}
onSyncService={(serviceId) => syncServiceMutation.mutate(serviceId)}
syncingServices={syncingServices}
/>
)}
</>}
{/* Creator Settings Modal (Instagram only) */}
{settingsCreator && (
<CreatorSettingsModal
creator={settingsCreator}
onClose={() => setSettingsCreator(null)}
/>
)}
{/* Add Creator Modal */}
<AddCreatorModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => queryClient.invalidateQueries({ queryKey: ['paid-content-creators'] })}
/>
{/* Delete Creator Confirmation Modal */}
{deleteTarget && (
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card rounded-xl border border-border shadow-lg w-full max-w-sm">
<div className="p-4 border-b border-border">
<h2 className="text-lg font-semibold text-foreground">Remove Creator</h2>
<p className="text-sm text-muted-foreground mt-1">
Are you sure you want to remove <strong>{deleteTarget.username}</strong>?
</p>
</div>
<div className="p-4 space-y-4">
<label className="flex items-start space-x-3 cursor-pointer p-3 rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/10">
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
className="rounded border-border mt-0.5"
/>
<div>
<span className="text-sm font-medium text-red-700 dark:text-red-400">Also delete downloaded files</span>
<p className="text-xs text-red-600/70 dark:text-red-400/70 mt-0.5">
This will permanently remove all downloaded media files from disk
</p>
</div>
</label>
<div className="flex items-center justify-end space-x-3">
<button
onClick={() => { setDeleteTarget(null); setDeleteFiles(false) }}
className="btn btn-secondary"
disabled={deleteMutation.isPending}
>
Cancel
</button>
<button
onClick={() => deleteMutation.mutate({ id: deleteTarget.id, deleteFiles })}
className="btn bg-red-600 hover:bg-red-700 text-white"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Removing...
</>
) : (
<>
<Trash2 className="w-4 h-4 mr-2" />
{deleteFiles ? 'Remove & Delete Files' : 'Remove'}
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}