1998 lines
82 KiB
TypeScript
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>
|
|
)
|
|
}
|