import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import { useSearchParams, Link as RouterLink } from 'react-router-dom'
import { useBreadcrumb } from '../../hooks/useBreadcrumb'
import { breadcrumbConfig } from '../../config/breadcrumbConfig'
import {
ScrollText,
Download,
Calendar,
User,
Image as ImageIcon,
Video,
File,
ChevronLeft,
X,
Heart,
Loader2,
Edit,
RefreshCw,
Save,
Link,
AlertTriangle,
LayoutGrid,
Tag,
Check,
CheckSquare,
Square,
Music,
Trash2,
Plus,
Columns,
Pin,
ChevronDown,
ImagePlus,
Play,
Lock,
ExternalLink,
MessageSquare,
Eye,
EyeOff,
Clock,
} from 'lucide-react'
import { FilterBar, FilterSection, ActiveFilter } from '../../components/FilterPopover'
import { api, wsClient, PaidContentPost, PaidContentAttachment, PaidContentCreator, PaidContentCreatorGroup } from '../../lib/api'
import { formatRelativeTime, formatPlatformName, decodeHtmlEntities, cleanCaption, THUMB_CACHE_V } from '../../lib/utils'
import PostDetailView from '../../components/paid-content/PostDetailView'
import BundleLightbox from '../../components/paid-content/BundleLightbox'
import { CopyToGalleryModal } from '../../components/private-gallery/CopyToGalleryModal'
import { useEnabledFeatures } from '../../hooks/useEnabledFeatures'
import TagSearchSelector from '../../components/private-gallery/TagSearchSelector'
// Alias for backwards compatibility in this file
const getServiceDisplayName = formatPlatformName
interface FeedFilters {
service?: string
platform?: string
creator_id?: number
creator_ids?: number[]
creator_group_id?: number
content_type?: string // 'image', 'video', 'audio'
min_resolution?: string // '720p', '1080p', '1440p', '4k'
tag_id?: number
tagged_user?: string
favorites_only: boolean
unviewed_only: boolean
downloaded_only: boolean
has_missing: boolean
missing_description: boolean
hide_empty: boolean
date_from?: string
date_to?: string
sort_by: string
sort_order: 'asc' | 'desc'
search?: string
}
// Format file size helper
function formatFileSize(bytes: number | null): string {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
// Format duration helper
function formatDuration(seconds: number | null): string {
if (!seconds) return ''
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
if (mins > 0) return `${mins}:${secs.toString().padStart(2, '0')}`
return `0:${secs.toString().padStart(2, '0')}`
}
// Generate display title for posts without titles
function getPostDisplayTitle(post: PaidContentPost): string {
// Use actual title if available
if (post.title && post.title.trim()) {
return post.title.trim()
}
// Build a descriptive title from available data
const parts: string[] = []
// Add date if available
if (post.published_at) {
const date = new Date(post.published_at)
parts.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }))
}
// Add attachment info
if (post.attachment_count && post.attachment_count > 0) {
const imageCount = post.attachments?.filter(a => a.file_type === 'image').length || 0
const videoCount = post.attachments?.filter(a => a.file_type === 'video').length || 0
if (videoCount > 0 && imageCount > 0) {
parts.push(`${imageCount} images, ${videoCount} videos`)
} else if (videoCount > 0) {
parts.push(`${videoCount} ${videoCount === 1 ? 'video' : 'videos'}`)
} else if (imageCount > 0) {
parts.push(`${imageCount} ${imageCount === 1 ? 'image' : 'images'}`)
} else {
parts.push(`${post.attachment_count} ${post.attachment_count === 1 ? 'file' : 'files'}`)
}
}
// If we have parts, join them
if (parts.length > 0) {
return parts.join(' — ')
}
// Last resort: use post_id
return `Post ${post.post_id?.slice(-8) || post.id}`
}
function AttachmentThumbnail({
attachment,
onClick,
onImport
}: {
attachment: PaidContentAttachment
onClick?: () => void
onImport?: (attachment: PaidContentAttachment) => void
}) {
const [thumbnailError, setThumbnailError] = useState(false)
// Preview frames are DRM video thumbnails (jpg) — treat as images, not videos
const pvImageExts = ['jpg', 'jpeg', 'png', 'webp', 'gif']
const isPF = attachment.file_type === 'video' && pvImageExts.includes(attachment.extension?.toLowerCase() || '')
const isImage = attachment.file_type === 'image' || isPF
const isVideo = attachment.file_type === 'video' && !isPF
const fileUrl = attachment.local_path
? attachment.file_token ? `/api/paid-content/files/serve?t=${encodeURIComponent(attachment.file_token)}` : `/api/paid-content/files/serve?path=${encodeURIComponent(attachment.local_path)}`
: null
const isMissing = attachment.status === 'failed' || attachment.status === 'pending'
const handleClick = () => {
if (onClick) {
onClick()
} else if (fileUrl && attachment.status === 'completed') {
window.open(fileUrl, '_blank')
}
}
// Build metadata string for tooltip
const metadata: string[] = []
if (attachment.name) metadata.push(attachment.name)
if (attachment.file_size) metadata.push(formatFileSize(attachment.file_size))
if (attachment.width && attachment.height) metadata.push(`${attachment.width}×${attachment.height}`)
if (attachment.duration) metadata.push(formatDuration(attachment.duration))
const canShowThumbnail = (isImage || isVideo) && attachment.status === 'completed' && attachment.id && !thumbnailError
// Use file_hash as cache buster so thumbnails refresh when a file is re-uploaded
const thumbCacheBuster = attachment.file_hash ? `v=${attachment.file_hash.slice(0, 8)}` : THUMB_CACHE_V
return (
{canShowThumbnail ? (
// Use cached thumbnail for both images and videos
setThumbnailError(true)}
/>
) : (
{isImage && }
{isVideo && }
{!isImage && !isVideo && }
)}
{/* Metadata overlay on hover */}
{attachment.name || `File ${attachment.attachment_index + 1}`}
{attachment.file_size && {formatFileSize(attachment.file_size)} }
{attachment.width && attachment.height && {attachment.width}×{attachment.height} }
{isVideo && attachment.duration && {formatDuration(attachment.duration)} }
{/* Status indicators */}
{attachment.status === 'completed' && (
)}
{attachment.status === 'pending' && (
Pending
)}
{attachment.status === 'failed' && (
Failed
)}
{isVideo && attachment.status === 'completed' && (
{attachment.duration ? formatDuration(attachment.duration) : 'Video'}
)}
{isPF && attachment.status === 'completed' && (
)}
{/* Import button for failed/pending attachments */}
{isMissing && onImport && (
{
e.stopPropagation()
onImport(attachment)
}}
className="absolute top-1 right-1 bg-blue-500 hover:bg-blue-600 text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity z-10"
title="Import from URL"
>
)}
)
}
// Import Modal for importing files from URLs or uploading directly
function ImportUrlModal({
attachment,
onClose,
onSuccess
}: {
attachment: PaidContentAttachment
onClose: () => void
onSuccess: () => void | Promise
}) {
const [mode, setMode] = useState<'url' | 'file'>('url')
const [url, setUrl] = useState('')
const [selectedFile, setSelectedFile] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [progress, setProgress] = useState(null)
const [error, setError] = useState(null)
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef(null)
// Real-time WebSocket progress updates for URL downloads
useEffect(() => {
if (!isDownloading) return
const handleProgress = (data: any) => {
if (data.attachment_id !== attachment.id) return
if (data.status === 'completed') {
setIsDownloading(false)
setProgress(null)
onSuccess()
onClose()
} else if (data.status === 'failed') {
setIsDownloading(false)
setIsLoading(false)
setError(data.message || 'Download failed')
} else if (data.status === 'downloading') {
setProgress(data.message || `Downloading: ${data.progress}%`)
}
}
const unsubscribe = wsClient.on('attachment_download_progress', handleProgress)
const pollInterval = setInterval(async () => {
try {
const att = await api.paidContent.getAttachment(attachment.id)
if (att.status === 'completed') {
setIsDownloading(false)
setProgress('Complete! Refreshing...')
await onSuccess()
onClose()
} else if (att.status === 'failed') {
setIsDownloading(false)
setIsLoading(false)
setError(att.error_message || 'Download failed')
} else if (att.status === 'downloading' && att.error_message) {
setProgress(att.error_message)
}
} catch (err) {
// Ignore polling errors
}
}, 2000)
return () => {
unsubscribe()
clearInterval(pollInterval)
}
}, [isDownloading, attachment.id, onSuccess, onClose])
const handleImportUrl = async () => {
if (!url.trim()) {
setError('Please enter a URL')
return
}
setIsLoading(true)
setError(null)
setProgress(null)
try {
const result = await api.paidContent.importUrlToAttachment(attachment.id, url.trim()) as {
status: string
error?: string
message?: string
}
if (result.status === 'success') {
await onSuccess()
onClose()
} else if (result.status === 'downloading') {
setIsDownloading(true)
setProgress('Starting download...')
} else {
setError(result.error || 'Import failed')
setIsLoading(false)
}
} catch (err: any) {
setError(err.message || 'Failed to import file')
setIsLoading(false)
}
}
const handleUploadFile = async () => {
if (!selectedFile) {
setError('Please select a file')
return
}
setIsLoading(true)
setError(null)
setProgress('Uploading: 0%')
try {
await api.paidContent.uploadFileToAttachment(
attachment.id,
selectedFile,
(loaded, total) => {
const pct = Math.round((loaded / total) * 100)
const loadedMB = (loaded / (1024 * 1024)).toFixed(1)
const totalMB = (total / (1024 * 1024)).toFixed(1)
setProgress(`Uploading: ${pct}% (${loadedMB}MB / ${totalMB}MB)`)
}
)
setProgress('Complete! Refreshing...')
await onSuccess()
onClose()
} catch (err: any) {
setError(err.message || 'Failed to upload file')
setIsLoading(false)
setProgress(null)
}
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) {
setSelectedFile(file)
setError(null)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = () => {
setIsDragging(false)
}
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
}
return (
e.stopPropagation()}
>
Import Attachment
{!isDownloading && !isLoading && (
)}
Import file for: {attachment.name || `Attachment ${attachment.attachment_index + 1}`}
{/* Mode tabs */}
{ setMode('url'); setError(null) }}
disabled={isLoading || isDownloading}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
mode === 'url'
? 'bg-blue-500 text-white'
: 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
>
From URL
{ setMode('file'); setError(null) }}
disabled={isLoading || isDownloading}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
mode === 'file'
? 'bg-blue-500 text-white'
: 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
>
Upload File
{/* URL input */}
{mode === 'url' && (
)}
{/* File upload */}
{mode === 'file' && (
!isLoading && fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDragging
? 'border-blue-500 bg-blue-500/10'
: selectedFile
? 'border-green-500 bg-green-500/10'
: 'border-border hover:border-blue-500 hover:bg-secondary'
}`}
>
{selectedFile ? (
{selectedFile.name}
{formatFileSize(selectedFile.size)}
Click to change file
) : (
Drop file here or click to browse
)}
{
const file = e.target.files?.[0]
if (file) {
setSelectedFile(file)
setError(null)
}
}}
/>
)}
{/* Progress display */}
{(isDownloading || (isLoading && mode === 'file')) && progress && (
{progress}
{progress.includes('%') && (
)}
)}
{error && (
)}
{isDownloading ? 'Downloading...' : 'Cancel'}
{isLoading ? (
<>
{mode === 'url' ? 'Starting...' : 'Uploading...'}
>
) : (
<>
{mode === 'url' ? 'Import' : 'Upload'}
>
)}
)
}
function PostCard({
post,
isSelected,
onClick,
onOpenLightbox,
onCopyToGallery,
}: {
post: PaidContentPost
isSelected: boolean
onClick: () => void
onOpenLightbox?: (attachmentIndex: number) => void
onCopyToGallery?: (post: PaidContentPost) => void
}) {
const queryClient = useQueryClient()
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null)
const favoriteMutation = useMutation({
mutationFn: () => api.paidContent.toggleFavorite(post.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
},
})
const viewedMutation = useMutation({
mutationFn: () => api.paidContent.toggleViewed(post.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-unviewed-count'] })
},
})
const { data: watchLaterIds = [] } = useQuery({
queryKey: ['paid-content-watch-later-ids'],
queryFn: () => api.paidContent.getWatchLaterAttachmentIds(),
staleTime: 30000,
})
const watchLaterSet = new Set(watchLaterIds)
const hasWatchLaterItems = post.attachments?.some(a => a.status === 'completed' && watchLaterSet.has(a.id))
const firstCompletedAttachment = post.attachments?.find(a => a.status === 'completed')
const addWatchLaterMutation = useMutation({
mutationFn: (attachmentId: number) => api.paidContent.addToWatchLater(attachmentId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['paid-content-watch-later-ids'] }),
})
const removeWatchLaterMutation = useMutation({
mutationFn: (attachmentId: number) => api.paidContent.removeFromWatchLater(attachmentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-watch-later-ids'] })
queryClient.invalidateQueries({ queryKey: ['paid-content-watch-later'] })
},
})
const toggleWatchLater = () => {
if (!firstCompletedAttachment) return
if (hasWatchLaterItems) {
// Remove all completed attachments from watch later
post.attachments?.filter(a => a.status === 'completed' && watchLaterSet.has(a.id))
.forEach(a => removeWatchLaterMutation.mutate(a.id))
} else {
// Add first completed attachment
addWatchLaterMutation.mutate(firstCompletedAttachment.id)
}
}
// Get completed attachments for display
const completedAttachments = post.attachments?.filter(a => a.status === 'completed') || []
const visualAttachments = completedAttachments.filter(a => a.file_type !== 'audio')
const unavailableAttachments = post.attachments?.filter(a => a.status === 'unavailable') || []
const imageExts = ['jpg', 'jpeg', 'png', 'webp', 'gif']
const previewFrameIds = new Set(
completedAttachments
.filter(a => a.file_type === 'video' && imageExts.includes(a.extension?.toLowerCase() || ''))
.map(a => a.id)
)
const hasMoreAttachments = (post.attachment_count || 0) > 4
// Handle touch to distinguish between tap and scroll
const handleTouchStart = (e: React.TouchEvent) => {
touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY, time: Date.now() }
}
const handleTouchEnd = (e: React.TouchEvent) => {
if (!touchStartRef.current) return
const touch = e.changedTouches[0]
const dx = Math.abs(touch.clientX - touchStartRef.current.x)
const dy = Math.abs(touch.clientY - touchStartRef.current.y)
const duration = Date.now() - touchStartRef.current.time
// Only trigger click if movement was less than 20px and touch lasted at least 50ms (deliberate tap)
if (dx < 20 && dy < 20 && duration >= 50) {
onClick()
}
touchStartRef.current = null
}
return (
{
// Only handle click on desktop (no touch events)
if (!('ontouchstart' in window)) {
onClick()
}
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Header row */}
{post.profile_image_url ? (
) : (
)}
{post.display_name || post.username}
@{post.username}
{post.is_pinned === 1 && (
)}
{onCopyToGallery && post.attachments?.some(a => a.status === 'completed' && a.local_path) && (
{
e.stopPropagation()
onCopyToGallery(post)
}}
className="hidden sm:block p-1.5 rounded-lg transition-colors text-muted-foreground hover:text-violet-500"
title="Copy to Private Gallery"
>
)}
{firstCompletedAttachment && (
{
e.stopPropagation()
toggleWatchLater()
}}
className={`p-1.5 rounded-lg transition-colors ${
hasWatchLaterItems ? 'text-violet-500' : 'text-muted-foreground hover:text-violet-500'
}`}
title={hasWatchLaterItems ? 'Remove from Watch Later' : 'Add to Watch Later'}
>
)}
{
e.stopPropagation()
favoriteMutation.mutate()
}}
className={`p-1.5 rounded-lg transition-colors ${
post.is_favorited ? 'text-red-500' : 'text-muted-foreground hover:text-red-500'
}`}
>
{
e.stopPropagation()
viewedMutation.mutate()
}}
className="p-1.5 rounded-lg transition-colors text-muted-foreground hover:text-foreground"
title={post.is_viewed ? 'Mark as unviewed' : 'Mark as viewed'}
>
{post.is_viewed ? : }
{/* Tags row */}
{(post.tags?.length || previewFrameIds.size > 0 || post.tags?.some(t => t.slug === 'ppv' || t.name === 'PPV')) ? (
{/* PPV tags first */}
{(post.tags || []).filter(t => t.slug === 'ppv' || t.name === 'PPV').map((tag) => (
{tag.name}
))}
{/* DRM badge second */}
{previewFrameIds.size > 0 && (
DRM
)}
{/* Remaining tags */}
{(post.tags || []).filter(t => t.slug !== 'ppv' && t.name !== 'PPV').map((tag) => (
{tag.name}
))}
) : null}
{/* Content - For YouTube/Twitch, use title instead of content */}
{(() => {
const isVideoService = ['youtube', 'twitch', 'pornhub'].includes(post.platform?.toLowerCase() || '')
const displayText = isVideoService && post.title ? post.title : (post.content || post.title)
return displayText ? (
{cleanCaption(decodeHtmlEntities(displayText))}
) : null
})()}
{/* PPV Locked Attachments - placeholder grid (mobile only) */}
{unavailableAttachments.length > 0 && completedAttachments.length === 0 && (
{unavailableAttachments.slice(0, 4).map((att) => (
{att.file_type === 'video' ? 'Video' : att.file_type === 'image' ? 'Image' : 'Media'} locked
))}
)}
{/* Attachment Grid - Inline thumbnails (mobile only, desktop uses detail panel) */}
{visualAttachments.length > 0 && (
{visualAttachments.slice(0, 4).map((att, idx) => {
const isImage = att.file_type === 'image'
const isVideo = att.file_type === 'video'
return (
e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
if (onOpenLightbox) {
onOpenLightbox(idx)
}
}}
>
{(isImage || isVideo) && att.id ? (
// Use cached thumbnail from database
{
// Hide broken image, show fallback
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
{/* Lock overlay for video preview frames (PPV/DRM) */}
{previewFrameIds.has(att.id) ? (
) : (
<>
{/* Play button overlay for videos */}
{isVideo && (
)}
{/* Duration badge for videos */}
{isVideo && att.duration && (
{Math.floor(att.duration / 60)}:{Math.floor(att.duration % 60).toString().padStart(2, '0')}
)}
>
)}
) : (
)}
{/* Show +N overlay on the 4th item if there are more */}
{idx === 3 && hasMoreAttachments && (
+{(post.attachment_count || 0) - 4}
)}
)
})}
)}
{/* Inline Audio Player - mobile only, prevents tap from opening post */}
{completedAttachments.some(a => a.file_type === 'audio') && (
e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
{completedAttachments.filter(a => a.file_type === 'audio').map((audio) => {
const audioUrl = audio.local_path
? audio.file_token ? `/api/paid-content/files/serve?t=${encodeURIComponent(audio.file_token)}` : `/api/paid-content/files/serve?path=${encodeURIComponent(audio.local_path)}`
: null
const fileSizeMB = audio.file_size ? (audio.file_size / 1024 / 1024).toFixed(1) : null
return (
{audio.name || 'Audio'}
{fileSizeMB &&
{fileSizeMB} MB
}
)
})}
)}
{/* Footer */}
{/* Content type tags */}
{(() => {
const imageCount = post.attachments?.filter(a => a.file_type === 'image').length || 0
const videoCount = post.attachments?.filter(a => a.file_type === 'video').length || 0
const audioCount = post.attachments?.filter(a => a.file_type === 'audio').length || 0
return (
<>
{imageCount > 0 && (
{imageCount}
)}
{videoCount > 0 && (
{videoCount}
)}
{audioCount > 0 && (
{audioCount}
)}
>
)
})()}
{post.downloaded && (
)}
)
}
function PostDetail({ post, onClose, onPostUpdated }: { post: PaidContentPost; onClose: () => void; onPostUpdated?: (updatedPost?: Partial) => void }) {
const [lightboxIndex, setLightboxIndex] = useState(null)
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(post.title || '')
const [editContent, setEditContent] = useState(post.content || '')
const [editDate, setEditDate] = useState(post.published_at?.split('T')[0] || '')
const [editTags, setEditTags] = useState(post.tags?.map(t => t.id) || [])
const [importAttachment, setImportAttachment] = useState(null)
const [isUploading, setIsUploading] = useState(false)
const [deletingAttachmentId, setDeletingAttachmentId] = useState(null)
const [confirmDeleteId, setConfirmDeleteId] = useState(null)
// Selection mode for batch delete
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState>(new Set())
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
const lastSelectedAttIdx = useRef(null)
const fileInputRef = useRef(null)
const queryClient = useQueryClient()
// Fetch available tags
const { data: availableTags = [] } = useQuery({
queryKey: ['paid-content-tags'],
queryFn: () => api.paidContent.getTags(),
})
// Update edit state when post changes
useEffect(() => {
setEditTitle(post.title || '')
setEditContent(post.content || '')
setEditDate(post.published_at?.split('T')[0] || '')
setEditTags(post.tags?.map(t => t.id) || [])
setIsEditing(false)
setConfirmDeleteId(null)
setDeletingAttachmentId(null)
setSelectionMode(false)
setSelectedIds(new Set())
}, [post.id])
// Mutation for updating post
const updateMutation = useMutation({
mutationFn: (data: { title?: string; content?: string; published_at?: string }) =>
api.put(`/paid-content/feed/${post.id}`, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
setIsEditing(false)
// Pass updated fields back to parent so selectedPost updates immediately
onPostUpdated?.(variables)
},
onError: (error) => {
console.error('Failed to update post:', error)
},
})
// Mutation for refreshing metadata
const refreshMutation = useMutation({
mutationFn: () => api.post(`/paid-content/feed/${post.id}/refresh`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
onPostUpdated?.()
},
})
// Mutation for updating tags
const tagsMutation = useMutation({
mutationFn: (tagIds: number[]) => api.paidContent.setPostTags(post.id, tagIds),
onSuccess: (_, tagIds) => {
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
setIsEditing(false)
// Pass updated tags back to parent so selectedPost updates immediately
const updatedTags = availableTags.filter(t => tagIds.includes(t.id))
onPostUpdated?.({ tags: updatedTags })
},
})
const handleSave = async () => {
const updates: { title?: string; content?: string; published_at?: string } = {}
// Normalize null/undefined to empty string for comparison
const originalTitle = post.title || ''
const originalContent = post.content || ''
const originalDate = post.published_at?.split('T')[0] || ''
if (editTitle !== originalTitle) updates.title = editTitle
if (editContent !== originalContent) updates.content = editContent
if (editDate !== originalDate) updates.published_at = editDate ? `${editDate}T12:00:00` : undefined
// Check if tags changed
const currentTagIds = post.tags?.map(t => t.id) || []
const tagsChanged = editTags.length !== currentTagIds.length ||
editTags.some(id => !currentTagIds.includes(id))
if (Object.keys(updates).length > 0) {
updateMutation.mutate(updates)
}
if (tagsChanged) {
tagsMutation.mutate(editTags)
}
if (Object.keys(updates).length === 0 && !tagsChanged) {
setIsEditing(false)
}
}
// Handle file upload
const handleFileUpload = async (event: React.ChangeEvent) => {
const files = event.target.files
if (!files || files.length === 0) return
setIsUploading(true)
try {
for (const file of Array.from(files)) {
await api.paidContent.uploadAttachment(post.id, file)
}
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
onPostUpdated?.()
} catch (error: any) {
console.error('Upload failed:', error)
alert(`Upload failed: ${error.message || 'Unknown error'}`)
} finally {
setIsUploading(false)
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
// Delete attachment handler
const handleDeleteAttachment = async (attachmentId: number) => {
// If this is the last attachment, offer to delete the entire post
if (completedAttachments.length === 1) {
setConfirmDeleteId(null)
if (confirm('This is the last attachment. Delete the entire post?')) {
try {
await api.paidContent.deletePost(post.id)
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
onClose()
} catch (error) {
console.error('Failed to delete post:', error)
alert('Failed to delete post')
}
}
return
}
setDeletingAttachmentId(attachmentId)
setConfirmDeleteId(null)
try {
await api.paidContent.deleteAttachment(post.id, attachmentId, true)
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
onPostUpdated?.()
} catch (error) {
console.error('Failed to delete attachment:', error)
alert('Failed to delete attachment')
} finally {
setDeletingAttachmentId(null)
}
}
// Batch delete handler
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return
// If deleting all attachments, offer to delete the post
if (selectedIds.size === completedAttachments.length) {
if (confirm(`Delete all ${completedAttachments.length} attachments and the entire post? This cannot be undone.`)) {
try {
setIsBatchDeleting(true)
await api.paidContent.deletePost(post.id)
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
onClose()
} catch (error) {
console.error('Failed to delete post:', error)
alert('Failed to delete post')
} finally {
setIsBatchDeleting(false)
}
}
return
}
if (!confirm(`Delete ${selectedIds.size} selected attachment${selectedIds.size > 1 ? 's' : ''}?`)) return
setIsBatchDeleting(true)
try {
for (const id of selectedIds) {
await api.paidContent.deleteAttachment(post.id, id, true)
}
setSelectedIds(new Set())
setSelectionMode(false)
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
onPostUpdated?.()
} catch (error) {
console.error('Failed to batch delete:', error)
alert('Some attachments failed to delete')
} finally {
setIsBatchDeleting(false)
}
}
// Lightbox delete handler (deletes current item and adjusts index)
const handleLightboxDelete = async (attachment: PaidContentAttachment) => {
if (!confirm('Delete this attachment?')) return
try {
await api.paidContent.deleteAttachment(post.id, attachment.id, true)
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
onPostUpdated?.()
// If no attachments left, close lightbox
const remaining = completedAttachments.filter(a => a.id !== attachment.id)
if (remaining.length === 0) {
setLightboxIndex(null)
return
}
// Adjust index if needed
if (lightboxIndex !== null && lightboxIndex >= remaining.length) {
setLightboxIndex(remaining.length - 1)
}
} catch (error) {
console.error('Failed to delete attachment:', error)
alert('Failed to delete attachment')
}
}
// Filter to only completed media attachments for lightbox navigation
const completedAttachments = post.attachments?.filter(
(a) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
) || []
const openLightbox = (attachment: PaidContentAttachment) => {
if (attachment.status !== 'completed') return
const index = completedAttachments.findIndex((a) => a.id === attachment.id)
if (index !== -1) {
setLightboxIndex(index)
}
}
return (
{/* BundleLightbox */}
{lightboxIndex !== null && completedAttachments.length > 0 && (
setLightboxIndex(null)}
onNavigate={setLightboxIndex}
onDelete={handleLightboxDelete}
/>
)}
{/* Header */}
{/* Mobile back button */}
{ e.preventDefault(); onClose(); }}
className="lg:hidden p-3 -ml-2 rounded-lg hover:bg-secondary active:bg-secondary/80 transition-colors flex items-center gap-2 text-foreground min-w-[44px] min-h-[44px]"
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
>
Back
{post.profile_image_url ? (
) : (
)}
{post.display_name || post.username}
{post.platform} / {post.published_at ? formatRelativeTime(post.published_at) : 'Unknown date'}
{/* Action buttons */}
{isEditing ? (
<>
{
e.stopPropagation()
if (selectionMode) {
setSelectionMode(false)
setSelectedIds(new Set())
} else {
setSelectionMode(true)
}
}}
className={`p-2 rounded-lg hover:bg-secondary active:bg-secondary/80 transition-colors min-w-[40px] min-h-[40px] flex items-center justify-center ${
selectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground'
}`}
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
title={selectionMode ? 'Cancel selection' : 'Select items to delete'}
>
setIsEditing(false)}
className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground"
title="Cancel"
>
{updateMutation.isPending || tagsMutation.isPending ? (
) : (
)}
>
) : (
<>
{
e.stopPropagation()
if (selectionMode) {
setSelectionMode(false)
setSelectedIds(new Set())
} else {
setSelectionMode(true)
}
}}
className={`p-2 rounded-lg hover:bg-secondary active:bg-secondary/80 transition-colors min-w-[40px] min-h-[40px] flex items-center justify-center ${
selectionMode ? 'text-primary bg-primary/10' : 'text-muted-foreground'
}`}
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
title={selectionMode ? 'Cancel selection' : 'Select items'}
>
refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground"
title="Refresh from source"
>
{refreshMutation.isPending ? (
) : (
)}
setIsEditing(true)}
className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground"
title="Edit"
>
{
e.stopPropagation()
if (confirm('Delete this post and all its media? This cannot be undone.')) {
try {
await api.paidContent.deletePost(post.id)
await queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
onClose()
} catch (error) {
console.error('Failed to delete post:', error)
alert('Failed to delete post')
}
}
}}
className="flex p-2 rounded-lg hover:bg-destructive/20 transition-colors min-w-[40px] min-h-[40px] items-center justify-center"
title="Delete post"
>
{/* Desktop close button */}
>
)}
{/* Content */}
{isEditing ? (
<>
Title
setEditTitle(e.target.value)}
className="w-full px-3 py-2 bg-secondary border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Post title"
/>
Date
setEditDate(e.target.value)}
className="w-full px-3 py-2 bg-secondary border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
Description
>
) : (
<>
{getPostDisplayTitle(post)}
{/* Display tags in view mode */}
{post.tags && post.tags.length > 0 && (
{post.tags.map((tag) => (
{tag.name}
))}
)}
{post.content && (
{cleanCaption(decodeHtmlEntities(post.content))}
)}
>
)}
{/* Attachments Grid */}
{/* Batch delete bar - shown when items are selected */}
{selectionMode && selectedIds.size > 0 && (
{selectedIds.size} selected
{
if (selectedIds.size === completedAttachments.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(completedAttachments.map(a => a.id)))
}
}}
className="text-xs text-primary hover:underline"
>
{selectedIds.size === completedAttachments.length ? 'Deselect all' : 'Select all'}
{isBatchDeleting ? (
) : (
)}
Delete
)}
{post.attachments && post.attachments.length > 0 ? (
{post.attachments.map((attachment, attIdx) => {
const isDeleting = deletingAttachmentId === attachment.id
const isConfirming = confirmDeleteId === attachment.id
const isSelected = selectedIds.has(attachment.id)
const handleAttSelection = (e: React.MouseEvent) => {
e.preventDefault()
setSelectedIds(prev => {
const next = new Set(prev)
if (e.shiftKey && lastSelectedAttIdx.current !== null && lastSelectedAttIdx.current !== attIdx) {
const start = Math.min(lastSelectedAttIdx.current, attIdx)
const end = Math.max(lastSelectedAttIdx.current, attIdx)
for (let i = start; i <= end; i++) {
next.add(post.attachments![i].id)
}
} else {
if (next.has(attachment.id)) {
next.delete(attachment.id)
} else {
next.add(attachment.id)
}
}
return next
})
if (!e.shiftKey) lastSelectedAttIdx.current = attIdx
}
return (
handleAttSelection(new MouseEvent('click') as unknown as React.MouseEvent) : isEditing ? undefined : () => openLightbox(attachment)}
onImport={(att) => setImportAttachment(att)}
/>
{/* Selection checkbox */}
{selectionMode && (
{
e.stopPropagation()
handleAttSelection(e)
}}
className={`absolute top-1 left-1 w-6 h-6 rounded-md border-2 flex items-center justify-center transition-colors z-10 ${
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'bg-black/50 border-white/70 text-transparent hover:border-white'
}`}
>
{isSelected && }
)}
{/* Delete button - only in edit mode */}
{isEditing && !selectionMode && !isConfirming && !isDeleting && (
{
e.stopPropagation()
setConfirmDeleteId(attachment.id)
}}
className="absolute top-1 right-1 p-1.5 rounded-full bg-red-500 text-white shadow-md hover:bg-red-600 transition-colors z-10"
title="Delete attachment"
>
)}
{/* Confirmation overlay */}
{isConfirming && (
Delete?
handleDeleteAttachment(attachment.id)}
className="px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
>
Yes
setConfirmDeleteId(null)}
className="px-2 py-1 text-xs bg-secondary text-foreground rounded hover:bg-secondary/80"
>
No
)}
{/* Deleting spinner overlay */}
{isDeleting && (
)}
)
})}
) : (
No attachments
{isEditing && (
fileInputRef.current?.click()}
disabled={isUploading}
className="mt-2 text-primary hover:underline text-sm"
>
Click to upload
)}
)}
{/* Batch delete bar (bottom) */}
{selectionMode && selectedIds.size > 0 && (
{selectedIds.size} selected
{
if (selectedIds.size === completedAttachments.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(completedAttachments.map(a => a.id)))
}
}}
className="text-xs text-primary hover:underline"
>
{selectedIds.size === completedAttachments.length ? 'Deselect all' : 'Select all'}
{isBatchDeleting ? (
) : (
)}
Delete
)}
{/* Import URL Modal */}
{importAttachment && (
setImportAttachment(null)}
onSuccess={async () => {
// Fetch updated post to get new attachment data
const updatedPost = await api.paidContent.getPost(post.id)
// Invalidate feed cache
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
// Update selectedPost with new attachments
onPostUpdated?.({ attachments: updatedPost.attachments })
}}
/>
)}
)
}
// Parse filters from URL search params
function parseFiltersFromParams(searchParams: URLSearchParams): FeedFilters {
const creatorIdsStr = searchParams.get('creator_ids')
return {
service: searchParams.get('service') || undefined,
platform: searchParams.get('platform') || undefined,
creator_id: searchParams.get('creator_id') ? parseInt(searchParams.get('creator_id')!) : undefined,
creator_ids: creatorIdsStr ? creatorIdsStr.split(',').map(Number).filter(n => !isNaN(n)) : undefined,
creator_group_id: searchParams.get('creator_group_id') ? parseInt(searchParams.get('creator_group_id')!) : undefined,
content_type: searchParams.get('content_type') || undefined,
min_resolution: searchParams.get('resolution') || undefined,
tag_id: searchParams.get('tag') ? parseInt(searchParams.get('tag')!) : undefined,
tagged_user: searchParams.get('tagged_user') || undefined,
favorites_only: searchParams.get('favorites') === 'true',
unviewed_only: searchParams.get('unviewed') === 'true',
downloaded_only: searchParams.get('downloaded') === 'true',
has_missing: searchParams.get('missing') === 'true',
missing_description: searchParams.get('nodesc') === 'true',
hide_empty: searchParams.get('hide_empty') !== 'false',
date_from: searchParams.get('from') || undefined,
date_to: searchParams.get('to') || undefined,
sort_by: searchParams.get('sort') || 'published_at',
sort_order: (searchParams.get('order') as 'asc' | 'desc') || 'desc',
search: searchParams.get('q') || undefined,
}
}
// Convert filters to URL search params
function filtersToParams(filters: FeedFilters): URLSearchParams {
const params = new URLSearchParams()
if (filters.service) params.set('service', filters.service)
if (filters.platform) params.set('platform', filters.platform)
if (filters.creator_id) params.set('creator_id', filters.creator_id.toString())
if (filters.creator_ids?.length) params.set('creator_ids', filters.creator_ids.join(','))
if (filters.creator_group_id) params.set('creator_group_id', filters.creator_group_id.toString())
if (filters.content_type) params.set('content_type', filters.content_type)
if (filters.min_resolution) params.set('resolution', filters.min_resolution)
if (filters.tag_id) params.set('tag', filters.tag_id.toString())
if (filters.tagged_user) params.set('tagged_user', filters.tagged_user)
if (filters.favorites_only) params.set('favorites', 'true')
if (filters.unviewed_only) params.set('unviewed', 'true')
if (filters.downloaded_only) params.set('downloaded', 'true')
if (filters.has_missing) params.set('missing', 'true')
if (filters.missing_description) params.set('nodesc', 'true')
if (!filters.hide_empty) params.set('hide_empty', 'false')
if (filters.date_from) params.set('from', filters.date_from)
if (filters.date_to) params.set('to', filters.date_to)
if (filters.sort_by && filters.sort_by !== 'published_at') params.set('sort', filters.sort_by)
if (filters.sort_order && filters.sort_order !== 'desc') params.set('order', filters.sort_order)
if (filters.search) params.set('q', filters.search)
return params
}
export default function PaidContentFeed() {
useBreadcrumb(breadcrumbConfig['/paid-content/feed'])
const { isFeatureEnabled } = useEnabledFeatures()
const [searchParams, setSearchParams] = useSearchParams()
const [selectedPost, setSelectedPost] = useState(null)
// Default to 'social' view on all devices
const [viewMode, setViewMode] = useState<'feed' | 'gallery' | 'social'>('social')
// Social view lightbox state
const [socialLightboxPost, setSocialLightboxPost] = useState(null)
const [socialLightboxIndex, setSocialLightboxIndex] = useState(0)
const [socialLightboxStartTime, setSocialLightboxStartTime] = useState(undefined)
const [socialLightboxWasPlaying, setSocialLightboxWasPlaying] = useState(false)
// State to pass back to PostDetailView when lightbox closes
const [videoReturnState, setVideoReturnState] = useState<{ attachmentId: number; time: number; wasPlaying: boolean } | null>(null)
// Mobile full post modal state
const [mobileSelectedPost, setMobileSelectedPost] = useState(null)
// Pinned posts collapsible section state (starts collapsed)
const [pinnedCollapsed, setPinnedCollapsed] = useState(true)
// Initialize filters from URL params
const [filters, setFilters] = useState(() => parseFiltersFromParams(searchParams))
// Sync filters to URL when they change (separate from state update to avoid render-time setState)
// Skip initial mount — filters are already derived from URL params, and calling
// setSearchParams on mount can interfere with React Router's startTransition
// causing the component to show empty state before data loads (especially on mobile SPA navigation)
const filtersInitializedRef = useRef(false)
useEffect(() => {
if (!filtersInitializedRef.current) {
filtersInitializedRef.current = true
return
}
const params = filtersToParams(filters)
setSearchParams(params, { replace: true })
}, [filters, setSearchParams])
const [showFilters, setShowFilters] = useState(false)
const scrollContainerRef = useRef(null)
// Helper to scroll to top of the list
const scrollToTop = useCallback(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' })
} else {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [])
// Scroll to top when filters change
const prevFilterKeyRef = useRef(null)
useEffect(() => {
const currentFilterKey = JSON.stringify(filters)
if (prevFilterKeyRef.current !== null && prevFilterKeyRef.current !== currentFilterKey) {
scrollToTop()
}
prevFilterKeyRef.current = currentFilterKey
}, [filters, scrollToTop])
// Refs to avoid stale closures in scroll callback
const hasNextPageRef = useRef(false)
const isFetchingNextPageRef = useRef(false)
const fetchNextPageRef = useRef<() => void>(() => {})
// Mobile detection for conditional ref attachment
const [isDesktop, setIsDesktop] = useState(() => typeof window !== 'undefined' && window.innerWidth >= 1024)
useEffect(() => {
const checkDesktop = () => setIsDesktop(window.innerWidth >= 1024)
window.addEventListener('resize', checkDesktop)
return () => window.removeEventListener('resize', checkDesktop)
}, [])
// Force feed view to social on mobile (feed needs split pane)
useEffect(() => {
if (!isDesktop && viewMode === 'feed') {
setViewMode('social')
}
}, [isDesktop, viewMode])
// Lightbox state for inline viewing from feed
const [lightboxPost, setLightboxPost] = useState(null)
const [lightboxIndex, setLightboxIndex] = useState(0)
// Slideshow state
const [slideshowActive, setSlideshowActive] = useState(false)
const [slideshowFlatAttachments, setSlideshowFlatAttachments] = useState([])
const [slideshowAttachmentPostMap, setSlideshowAttachmentPostMap] = useState>(new Map())
const [slideshowPost, setSlideshowPost] = useState(null)
const [slideshowIndex, setSlideshowIndex] = useState(0)
// Backend-driven shuffle state (mirrors private gallery pattern)
const [shuffleSeed, setShuffleSeed] = useState(0)
const [shuffleEnabled, setShuffleEnabled] = useState(false)
const [shuffleLoading, setShuffleLoading] = useState(false)
const [shuffleHasMore, setShuffleHasMore] = useState(false)
const [shuffleTotal, setShuffleTotal] = useState(0)
const shuffleOffsetRef = useRef(0)
// Tag mode state for bulk tagging in gallery view
const [tagMode, setTagMode] = useState(false)
const [selectedPostIds, setSelectedPostIds] = useState>(new Set())
const [selectedTagIds, setSelectedTagIds] = useState>(new Set())
// Copy to Private Gallery modal state
const [showCopyToGalleryModal, setShowCopyToGalleryModal] = useState(false)
const [copyPost, setCopyPost] = useState(null)
const limit = 50
// Debounce search to avoid firing API calls on every keystroke
const [debouncedSearch, setDebouncedSearch] = useState(filters.search)
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(filters.search), 300)
return () => clearTimeout(timer)
}, [filters.search])
// Build query filters using debounced search value
const queryFilters = useMemo(() => ({
...filters,
search: debouncedSearch,
}), [filters, debouncedSearch])
// Stable filter key for query - ensures proper cache invalidation
const filterKey = JSON.stringify(queryFilters)
// Infinite query for endless scrolling
const {
data,
isLoading,
isPending,
isError,
error: _feedError,
refetch,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ['paid-content-feed', filterKey],
queryFn: ({ pageParam = 0 }) =>
api.paidContent.getFeed({
...queryFilters,
limit,
offset: pageParam * limit,
}),
getNextPageParam: (lastPage, allPages) => {
// Handle both array response and object response
if (!lastPage) return undefined
const pageData = Array.isArray(lastPage) ? lastPage : (lastPage as any)?.posts || []
// If the last page has fewer items than limit, we've reached the end
if (!pageData || !Array.isArray(pageData) || pageData.length < limit) return undefined
// Use optional chaining for safety (allPages may be undefined in some react-query versions)
return allPages?.length ?? 0
},
initialPageParam: 0,
refetchOnMount: 'always',
staleTime: 0,
gcTime: 5 * 60 * 1000, // Keep cached data for 5 minutes so navigating back shows content instantly
retry: 3, // Retry more for mobile network resilience
})
// Flatten all pages into a single array, deduplicating by post ID
// (offset-based pagination can produce duplicates when data shifts between page fetches)
const pages = data?.pages
const posts = useMemo(() => {
if (!Array.isArray(pages)) return []
const seen = new Set()
return pages.flatMap(page =>
Array.isArray(page) ? page : (page as any)?.posts || []
).filter((post: any) => {
if (seen.has(post.id)) return false
seen.add(post.id)
return true
})
}, [pages])
// Split posts into pinned vs regular for collapsible section
const pinnedPosts = posts.filter(p => p.is_pinned === 1)
const regularPosts = posts.filter(p => p.is_pinned !== 1)
// Gallery view: flat attachment list across all posts for lightbox navigation
const galleryFlatAttachments = useMemo(() => {
if (viewMode !== 'gallery' || !posts || posts.length === 0) return []
const flat: PaidContentAttachment[] = []
for (const p of [...pinnedPosts, ...regularPosts]) {
const completed = (p.attachments || []).filter(
(a: PaidContentAttachment) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
)
for (const att of completed) flat.push(att)
}
return flat
}, [viewMode, posts, pinnedPosts, regularPosts])
const galleryAttachmentPostMap = useMemo(() => {
if (viewMode !== 'gallery' || !posts || posts.length === 0) return new Map()
const postMap = new Map()
for (const p of [...pinnedPosts, ...regularPosts]) {
for (const att of (p.attachments || [])) {
postMap.set(att.id, p)
}
}
return postMap
}, [viewMode, posts, pinnedPosts, regularPosts])
// Keep refs in sync for IntersectionObserver callback (avoids stale closures)
useEffect(() => {
hasNextPageRef.current = !!hasNextPage
isFetchingNextPageRef.current = isFetchingNextPage
fetchNextPageRef.current = fetchNextPage
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
// Fetch a page of shuffled posts from the backend
const fetchShufflePage = useCallback(async (seed: number, offset: number, reset: boolean) => {
setShuffleLoading(true)
try {
const response = await api.paidContent.getFeed({
...queryFilters,
shuffle: true,
shuffle_seed: seed,
limit: 200,
offset,
})
const newAttachments: PaidContentAttachment[] = []
const newPostMap = new Map()
for (const post of (response.posts || [])) {
const completed = (post.attachments || []).filter(
(a: PaidContentAttachment) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
)
for (const att of completed) {
newAttachments.push(att)
newPostMap.set(att.id, post)
}
}
// Shuffle individual attachments (backend shuffles posts, but we need
// individual photos mixed across posts, not grouped by post)
const rng = (s: number) => {
// Simple seeded PRNG (mulberry32)
return () => { s |= 0; s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t ^= t + Math.imul(t ^ t >>> 7, 61 | t); return ((t ^ t >>> 14) >>> 0) / 4294967296 }
}
const random = rng(seed + offset)
for (let i = newAttachments.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[newAttachments[i], newAttachments[j]] = [newAttachments[j], newAttachments[i]]
}
if (reset) {
setSlideshowFlatAttachments(newAttachments)
setSlideshowAttachmentPostMap(newPostMap)
} else {
setSlideshowFlatAttachments(prev => [...prev, ...newAttachments])
setSlideshowAttachmentPostMap(prev => {
const merged = new Map(prev)
newPostMap.forEach((v, k) => merged.set(k, v))
return merged
})
}
setShuffleTotal(response.total_media || response.total || 0)
setShuffleHasMore((response.posts || []).length === 200)
shuffleOffsetRef.current = offset + 200
} catch (e) {
console.error('Failed to fetch shuffle page:', e)
} finally {
setShuffleLoading(false)
}
}, [queryFilters])
// Rebuild slideshow flat list when posts change (new pages loaded during slideshow)
// Only for non-shuffle mode (shuffle mode uses fetchShufflePage)
useEffect(() => {
if (!slideshowActive || shuffleEnabled || !posts || posts.length === 0) return
const flatAttachments: PaidContentAttachment[] = []
const postMap = new Map()
for (const p of posts) {
const completed = (p.attachments || []).filter(
(a: PaidContentAttachment) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
)
for (const att of completed) {
flatAttachments.push(att)
postMap.set(att.id, p)
}
}
if (flatAttachments.length > slideshowFlatAttachments.length) {
setSlideshowFlatAttachments(flatAttachments)
setSlideshowAttachmentPostMap(postMap)
}
}, [slideshowActive, shuffleEnabled, posts, slideshowFlatAttachments.length])
// When pinned posts are collapsed and a pinned post is selected, switch to first regular post
useEffect(() => {
if (pinnedCollapsed && selectedPost && selectedPost.is_pinned === 1 && regularPosts.length > 0) {
setSelectedPost(regularPosts[0])
}
}, [pinnedCollapsed])
// Keyboard navigation for feed and social views
useEffect(() => {
if (viewMode !== 'social' && viewMode !== 'feed') return
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
// Don't handle if lightbox is open
if (socialLightboxPost || lightboxPost) return
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
// When pinned are collapsed, only navigate through regular posts
const navPosts = pinnedCollapsed ? regularPosts : posts
if (!navPosts || navPosts.length === 0) return
const currentIndex = selectedPost
? navPosts.findIndex((p: PaidContentPost) => p.id === selectedPost.id)
: -1
let newIndex: number
if (e.key === 'ArrowDown') {
newIndex = currentIndex < navPosts.length - 1 ? currentIndex + 1 : currentIndex
} else {
newIndex = currentIndex > 0 ? currentIndex - 1 : 0
}
if (newIndex !== currentIndex && navPosts[newIndex]) {
const newPost = navPosts[newIndex]
setSelectedPost(newPost)
// Scroll the post into view in the list
const postElement = scrollContainerRef.current?.querySelector(`[data-post-id="${newPost.id}"]`)
if (postElement) {
postElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [viewMode, posts, regularPosts, pinnedCollapsed, selectedPost, socialLightboxPost, lightboxPost])
// Get total count from first page response
const totalFilteredPosts = (pages?.[0] as any)?.total ?? 0
const totalFilteredMedia = (pages?.[0] as any)?.total_media ?? 0
const { data: creators } = useInfiniteQuery({
queryKey: ['paid-content-creators-list'],
queryFn: () => api.paidContent.getCreators(),
getNextPageParam: () => undefined,
initialPageParam: 0,
})
// Handle both array and object responses for creators
const creatorsPages = creators?.pages
const creatorsList = Array.isArray(creatorsPages) ? creatorsPages.flatMap(page =>
Array.isArray(page) ? page : (page as any)?.creators || []
) : []
// Service to platforms mapping
const servicePlatforms: Record = {
coomer: ['onlyfans', 'fansly', 'candfans'],
kemono: ['patreon', 'fanbox', 'gumroad', 'subscribestar', 'discord'],
}
// Derive unique platforms and services from creators
const allPlatforms = (creatorsList && creatorsList.length > 0)
? [...new Set(creatorsList.map((c: PaidContentCreator) => c.platform))].sort()
: []
const availableServices = (creatorsList && creatorsList.length > 0)
? [...new Set(creatorsList.map((c: PaidContentCreator) => c.service_id))].sort()
: []
// Filter platforms based on selected service
// Filter creators based on selected service and/or platform
const filteredCreatorsList = creatorsList.filter((c: PaidContentCreator) => {
if (filters.service && c.service_id !== filters.service) return false
if (filters.platform && c.platform !== filters.platform) return false
return true
})
// Creator groups for filtering
const { data: creatorGroups = [] } = useQuery({
queryKey: ['paid-content-creator-groups'],
queryFn: () => api.paidContent.getCreatorGroups(),
})
// Fetch selected group details to show its members in the creator filter
const { data: selectedGroupDetail } = useQuery({
queryKey: ['paid-content-creator-group', filters.creator_group_id],
queryFn: () => api.paidContent.getCreatorGroup(filters.creator_group_id!),
enabled: !!filters.creator_group_id,
})
// When a group is selected, show its member IDs in the creator multiselect
const groupMemberIds = new Set(
selectedGroupDetail?.members?.map((m: { id: number }) => m.id) || []
)
// Compute active creator IDs for scoping filter dropdowns
const activeCreatorIds = useMemo(() => {
if (filters.creator_ids?.length) return filters.creator_ids
if (filters.creator_id) return [filters.creator_id]
if (filters.creator_group_id && selectedGroupDetail?.members?.length) {
return selectedGroupDetail.members.map((m: { id: number }) => m.id)
}
return undefined
}, [filters.creator_ids, filters.creator_id, filters.creator_group_id, selectedGroupDetail])
// Tags for filtering and bulk tagging (scoped to selected creators)
const { data: availableTags = [] } = useQuery({
queryKey: ['paid-content-tags', activeCreatorIds],
queryFn: () => api.paidContent.getTags(activeCreatorIds),
})
// Tagged users for filtering (scoped to selected creators)
const { data: taggedUsersList = [] } = useQuery({
queryKey: ['paid-content-tagged-users', activeCreatorIds],
queryFn: () => api.paidContent.getTaggedUsers(activeCreatorIds),
})
// Content types for filtering (scoped to selected creators)
const { data: scopedContentTypes } = useQuery({
queryKey: ['paid-content-content-types', activeCreatorIds],
queryFn: () => api.paidContent.getContentTypes(activeCreatorIds),
enabled: !!activeCreatorIds?.length,
})
// Scoped filters: when a group is selected, narrow Service/Platform/Creator to group members
const scopedServices = useMemo(() => {
if (!filters.creator_group_id || !selectedGroupDetail?.members) return availableServices
const memberServices = new Set(selectedGroupDetail.members.map((m: any) => m.service_id))
return availableServices.filter((s: string) => memberServices.has(s))
}, [filters.creator_group_id, selectedGroupDetail, availableServices])
const scopedAllPlatforms = useMemo(() => {
if (!filters.creator_group_id || !selectedGroupDetail?.members) return allPlatforms
const memberPlatforms = new Set(selectedGroupDetail.members.map((m: any) => m.platform))
return allPlatforms.filter((p: string) => memberPlatforms.has(p))
}, [filters.creator_group_id, selectedGroupDetail, allPlatforms])
const scopedAvailablePlatforms = useMemo(() => {
if (filters.service) {
return scopedAllPlatforms.filter((p: string) => servicePlatforms[filters.service!]?.includes(p))
}
return scopedAllPlatforms
}, [filters.service, scopedAllPlatforms, servicePlatforms])
const queryClient = useQueryClient()
// Mutation for applying tags to multiple posts
const applyTagsMutation = useMutation({
mutationFn: ({ postIds, tagIds }: { postIds: number[]; tagIds: number[] }) =>
api.paidContent.addTagsToPosts(postIds, tagIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
setSelectedPostIds(new Set())
setSelectedTagIds(new Set())
setTagMode(false)
},
})
// Toggle post selection in tag mode
const togglePostSelection = (postId: number) => {
setSelectedPostIds(prev => {
const newSet = new Set(prev)
if (newSet.has(postId)) {
newSet.delete(postId)
} else {
newSet.add(postId)
}
return newSet
})
}
// Toggle tag selection
const toggleTagSelection = (tagId: number) => {
setSelectedTagIds(prev => {
const newSet = new Set(prev)
if (newSet.has(tagId)) {
newSet.delete(tagId)
} else {
newSet.add(tagId)
}
return newSet
})
}
// Select all visible posts
const selectAllPosts = () => {
const allPostIds = new Set(posts.map(p => p.id))
setSelectedPostIds(allPostIds)
}
// Clear selections and exit tag mode
const exitTagMode = () => {
setTagMode(false)
setSelectedPostIds(new Set())
setSelectedTagIds(new Set())
}
// Apply selected tags to selected posts
const applyTags = () => {
if (selectedPostIds.size === 0 || selectedTagIds.size === 0) return
applyTagsMutation.mutate({
postIds: Array.from(selectedPostIds),
tagIds: Array.from(selectedTagIds),
})
}
// Unviewed count query
const { data: unviewedData } = useQuery({
queryKey: ['paid-content-unviewed-count'],
queryFn: () => api.paidContent.getUnviewedCount(),
staleTime: 60000,
refetchInterval: 60000,
})
const unviewedCount = unviewedData?.count ?? 0
// Mark all viewed mutation
const markAllViewedMutation = useMutation({
mutationFn: () => api.paidContent.markAllViewed(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-unviewed-count'] })
queryClient.resetQueries({ queryKey: ['paid-content-feed'] })
if (filters.unviewed_only) {
handleFilterChange('unviewed_only', false)
}
},
})
// Unread messages count query
const { data: unreadMessagesData } = useQuery({
queryKey: ['paid-content-unread-messages-count'],
queryFn: () => api.paidContent.getUnreadMessagesCount(),
staleTime: 60000,
refetchInterval: 60000,
})
const unreadMessagesCount = unreadMessagesData?.count ?? 0
// Mark all messages read mutation
const markAllMessagesReadMutation = useMutation({
mutationFn: () => api.paidContent.markAllMessagesRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paid-content-unread-messages-count'] })
},
})
// Infinite scroll using scroll event
useEffect(() => {
// On mobile, always use window scroll. On desktop, use scroll container if available.
const scrollContainer = isDesktop ? scrollContainerRef.current : null
const handleScroll = () => {
if (!hasNextPageRef.current || isFetchingNextPageRef.current) return
let shouldFetch = false
if (scrollContainer) {
// Desktop: check scroll container
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
shouldFetch = scrollHeight - scrollTop - clientHeight < 500
} else {
// Mobile: check window/document scroll
const scrollTop = window.scrollY || document.documentElement.scrollTop
const scrollHeight = document.documentElement.scrollHeight
const clientHeight = window.innerHeight
shouldFetch = scrollHeight - scrollTop - clientHeight < 500
}
if (shouldFetch) {
fetchNextPageRef.current()
}
}
// Attach to appropriate scroll target
const target = scrollContainer || window
target.addEventListener('scroll', handleScroll, { passive: true })
// Check immediately and after a short delay (for initial render)
handleScroll()
const timeoutId = setTimeout(handleScroll, 500)
return () => {
target.removeEventListener('scroll', handleScroll)
clearTimeout(timeoutId)
}
}, [viewMode, isDesktop])
// After data changes (e.g. filter change loads page 0), re-check if the container
// isn't full and more pages should be auto-loaded to fill the visible area
useEffect(() => {
if (!hasNextPage || isFetchingNextPage) return
const checkAndLoad = () => {
const scrollContainer = isDesktop ? scrollContainerRef.current : null
if (scrollContainer) {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
if (scrollHeight - scrollTop - clientHeight < 500) {
fetchNextPage()
}
}
}
// Small delay to let the DOM render after data change
const timer = setTimeout(checkAndLoad, 100)
return () => clearTimeout(timer)
}, [data, hasNextPage, isFetchingNextPage, fetchNextPage, isDesktop])
// Deep-link to a specific post via ?post= param
const [initialPostId] = useState(() => {
const postParam = searchParams.get('post')
return postParam ? parseInt(postParam) : null
})
const [initialPostLoaded, setInitialPostLoaded] = useState(false)
useEffect(() => {
if (!initialPostId || initialPostLoaded) return
const selectPost = (post: PaidContentPost) => {
if (window.innerWidth < 1024) {
setMobileSelectedPost(post)
} else {
setSelectedPost(post)
}
}
// First check if the post is already in the loaded feed
const found = posts?.find((p: PaidContentPost) => p.id === initialPostId)
if (found) {
selectPost(found)
setInitialPostLoaded(true)
// Clean the param from URL
setSearchParams(prev => {
const next = new URLSearchParams(prev)
next.delete('post')
return next
}, { replace: true })
return
}
// If posts have loaded but post not found, fetch it directly
if (posts && posts.length > 0) {
api.paidContent.getPost(initialPostId).then(post => {
if (post) {
selectPost(post)
}
setInitialPostLoaded(true)
setSearchParams(prev => {
const next = new URLSearchParams(prev)
next.delete('post')
return next
}, { replace: true })
}).catch(() => {
setInitialPostLoaded(true)
})
}
}, [initialPostId, initialPostLoaded, posts, setSearchParams])
// Auto-select first post on desktop only (feed and social views)
useEffect(() => {
if (initialPostId && !initialPostLoaded) return // Wait for deep-linked post first
if (posts && posts.length > 0 && !selectedPost && window.innerWidth >= 1024 && (viewMode === 'feed' || viewMode === 'social')) {
const firstRegular = posts.find(p => p.is_pinned !== 1)
if (firstRegular) {
setSelectedPost(firstRegular)
} else if (hasNextPage && !isFetchingNextPage) {
// All loaded posts are pinned — fetch next page to find a regular post
fetchNextPage()
}
}
}, [posts, selectedPost, viewMode, initialPostId, initialPostLoaded, hasNextPage, isFetchingNextPage, fetchNextPage])
const handleFilterChange = useCallback((key: keyof FeedFilters, value: any) => {
setFilters((prev) => {
const updated = { ...prev, [key]: value }
// Clear dependent filters when parent filter changes
if (key === 'service') {
// When service changes, check if platform is still valid
if (updated.platform && value) {
const validPlatforms = servicePlatforms[value] || []
if (!validPlatforms.includes(updated.platform)) {
updated.platform = undefined
}
}
// Check if creator is still valid for the new service
if (updated.creator_id) {
const creator = creatorsList.find((c: PaidContentCreator) => c.id === updated.creator_id)
if (creator && creator.service_id !== value) {
updated.creator_id = undefined
}
}
}
if (key === 'platform') {
// When platform changes, check if creator is still valid
if (updated.creator_id && value) {
const creator = creatorsList.find((c: PaidContentCreator) => c.id === updated.creator_id)
if (creator && creator.platform !== value) {
updated.creator_id = undefined
}
}
}
// When creator selection changes, clear scoped filters that may become stale
if (key === 'creator_id' || key === 'creator_ids' || key === 'creator_group_id') {
updated.tag_id = undefined
updated.tagged_user = undefined
updated.content_type = undefined
}
return updated
})
setSelectedPost(null)
}, [creatorsList, setFilters, setSelectedPost])
const clearFilters = () => {
setFilters({
favorites_only: false,
unviewed_only: false,
downloaded_only: false,
has_missing: false,
missing_description: false,
hide_empty: true,
sort_by: 'published_at',
sort_order: 'desc',
date_from: undefined,
date_to: undefined,
min_resolution: undefined,
creator_ids: undefined,
creator_group_id: undefined,
})
}
const hasActiveFilters =
filters.service ||
filters.platform ||
filters.creator_id ||
(filters.creator_ids && filters.creator_ids.length > 0) ||
filters.creator_group_id ||
filters.content_type ||
filters.min_resolution ||
filters.tag_id ||
filters.tagged_user ||
filters.favorites_only ||
filters.unviewed_only ||
filters.downloaded_only ||
filters.has_missing ||
filters.missing_description ||
!filters.hide_empty ||
filters.search ||
filters.date_from ||
filters.date_to
// Build filter sections for FilterBar component
const filterSections: FilterSection[] = useMemo(() => [
{
id: 'creator_group',
label: 'Creator Group',
type: 'select' as const,
options: [
{ value: '', label: 'All Groups' },
...creatorGroups.map((group: PaidContentCreatorGroup) => ({
value: group.id.toString(),
label: group.name,
})),
],
value: filters.creator_group_id?.toString() || '',
onChange: (value) => {
const groupId = value ? Number(value as string) : undefined
setFilters(prev => ({
...prev,
creator_group_id: groupId,
// Clear sub-filters when selecting a group
creator_id: groupId ? undefined : prev.creator_id,
creator_ids: groupId ? undefined : prev.creator_ids,
service: groupId ? undefined : prev.service,
platform: groupId ? undefined : prev.platform,
tag_id: groupId ? undefined : prev.tag_id,
}))
setSelectedPost(null)
},
},
{
id: 'creator',
label: filters.creator_group_id ? 'Group Members' : 'Creator',
type: 'multiselect' as const,
options: (filters.creator_group_id
? filteredCreatorsList.filter((c: any) => groupMemberIds.has(c.id))
: filteredCreatorsList
).map((creator: any) => ({
value: creator.id.toString(),
label: `${creator.username} (${creator.platform})`,
})),
value: filters.creator_group_id
? [...groupMemberIds].map(String)
: (filters.creator_ids?.map(String) || []),
onChange: (value) => {
const ids = (value as string[]).map(Number).filter(n => !isNaN(n))
if (filters.creator_group_id) {
// When a group is active, deselecting members narrows within the group
// by switching to creator_ids mode
if (ids.length < groupMemberIds.size && ids.length > 0) {
setFilters(prev => ({
...prev,
creator_ids: ids,
creator_id: undefined,
creator_group_id: undefined,
}))
} else if (ids.length === 0) {
// Cleared all — remove group filter entirely
setFilters(prev => ({
...prev,
creator_ids: undefined,
creator_id: undefined,
creator_group_id: undefined,
}))
}
// If all selected, keep the group filter as-is (no-op)
} else {
setFilters(prev => ({
...prev,
creator_ids: ids.length > 0 ? ids : undefined,
creator_id: undefined,
creator_group_id: undefined,
}))
}
setSelectedPost(null)
},
},
{
id: 'service',
label: 'Service',
type: 'select' as const,
options: [
{ value: '', label: 'All Services' },
...scopedServices.map((service: string) => ({
value: service,
label: getServiceDisplayName(service),
})),
],
value: filters.service || '',
onChange: (value) => handleFilterChange('service', (value as string) || undefined),
},
{
id: 'platform',
label: 'Platform',
type: 'select' as const,
options: [
{ value: '', label: 'All Platforms' },
...scopedAvailablePlatforms.map((platform: string) => ({
value: platform,
label: platform.charAt(0).toUpperCase() + platform.slice(1),
})),
],
value: filters.platform || '',
onChange: (value) => handleFilterChange('platform', (value as string) || undefined),
},
{
id: 'content_type',
label: 'Content Type',
type: 'select' as const,
options: [
{ value: '', label: 'All Types' },
...(scopedContentTypes && activeCreatorIds?.length
? scopedContentTypes
: ['image', 'video', 'audio']
).map(t => ({
value: t,
label: t === 'image' ? 'Images' : t === 'video' ? 'Videos' : t === 'audio' ? 'Audio' : t.charAt(0).toUpperCase() + t.slice(1),
})),
],
value: filters.content_type || '',
onChange: (value) => handleFilterChange('content_type', (value as string) || undefined),
},
{
id: 'tag',
label: 'Tag',
type: 'select' as const,
options: [
{ value: '', label: 'All Tags' },
...availableTags
.filter((tag: { id: number; name: string; color: string; post_count?: number }) => (tag.post_count ?? 0) > 0)
.map((tag: { id: number; name: string; color: string; post_count?: number }) => ({
value: tag.id.toString(),
label: `${tag.name} (${tag.post_count})`,
})),
],
value: filters.tag_id?.toString() || '',
onChange: (value) => handleFilterChange('tag_id', value ? parseInt(value as string) : undefined),
},
{
id: 'tagged_user',
label: 'Tagged User',
type: 'select' as const,
options: [
{ value: '', label: 'All Tagged Users' },
{ value: '__none__', label: 'No Tagged Users' },
...taggedUsersList
.sort((a: {username: string}, b: {username: string}) => a.username.localeCompare(b.username))
.map((tu: {username: string, post_count: number}) => ({
value: tu.username,
label: `@${tu.username} (${tu.post_count})`,
})),
],
value: filters.tagged_user || '',
onChange: (value) => handleFilterChange('tagged_user', (value as string) || undefined),
},
{
id: 'resolution',
label: 'Min Resolution',
type: 'select' as const,
options: [
{ value: '', label: 'Any Resolution' },
{ value: '720p', label: '720p+' },
{ value: '1080p', label: '1080p+' },
{ value: '1440p', label: '1440p+' },
{ value: '4k', label: '4K+' },
],
value: filters.min_resolution || '',
onChange: (value) => handleFilterChange('min_resolution', (value as string) || undefined),
},
{
id: 'sort',
label: 'Sort By',
type: 'select' as const,
options: [
{ value: 'published_at-desc', label: 'Newest First' },
{ value: 'published_at-asc', label: 'Oldest First' },
{ value: 'added_at-desc', label: 'Recently Added' },
{ value: 'download_date-desc', label: 'Recently Downloaded' },
],
value: `${filters.sort_by}-${filters.sort_order}`,
onChange: (value) => {
const [sortBy, sortOrder] = (value as string).split('-')
setFilters((prev) => ({ ...prev, sort_by: sortBy, sort_order: sortOrder as 'asc' | 'desc' }))
setSelectedPost(null)
},
},
{
id: 'status',
label: 'Status',
type: 'select' as const,
options: [
{ value: '', label: 'All Status' },
{ value: 'favorites', label: 'Favorites Only' },
{ value: 'unviewed', label: 'Unviewed Only' },
{ value: 'downloaded', label: 'Downloaded Only' },
{ value: 'missing', label: 'Missing Files Only' },
{ value: 'nodesc', label: 'Missing Description' },
{ value: 'empty', label: 'Empty Posts Only' },
],
value: filters.favorites_only ? 'favorites' :
filters.unviewed_only ? 'unviewed' :
filters.downloaded_only ? 'downloaded' :
filters.has_missing ? 'missing' :
filters.missing_description ? 'nodesc' :
!filters.hide_empty ? 'empty' : '',
onChange: (value) => {
const v = value as string
// Batch all status changes in a single setFilters call to avoid
// multiple intermediate states and unnecessary fetches
setFilters(prev => ({
...prev,
favorites_only: v === 'favorites',
unviewed_only: v === 'unviewed',
downloaded_only: v === 'downloaded',
has_missing: v === 'missing',
missing_description: v === 'nodesc',
hide_empty: v !== 'empty',
}))
setSelectedPost(null)
},
},
], [filters, scopedServices, scopedAvailablePlatforms, filteredCreatorsList, availableTags, taggedUsersList, creatorGroups, groupMemberIds, handleFilterChange, setFilters, setSelectedPost])
// Build active filters array for FilterBar
const activeFilters: ActiveFilter[] = useMemo(() => {
const result: ActiveFilter[] = []
if (filters.service) {
result.push({
id: 'service',
label: 'Service',
value: filters.service,
displayValue: getServiceDisplayName(filters.service),
onRemove: () => handleFilterChange('service', undefined),
})
}
if (filters.platform) {
result.push({
id: 'platform',
label: 'Platform',
value: filters.platform,
displayValue: filters.platform.charAt(0).toUpperCase() + filters.platform.slice(1),
onRemove: () => handleFilterChange('platform', undefined),
})
}
if (filters.creator_group_id) {
const group = creatorGroups.find((g: PaidContentCreatorGroup) => g.id === filters.creator_group_id)
result.push({
id: 'creator_group',
label: 'Group',
value: filters.creator_group_id.toString(),
displayValue: group?.name || `Group ${filters.creator_group_id}`,
onRemove: () => handleFilterChange('creator_group_id', undefined),
})
}
if (filters.creator_ids && filters.creator_ids.length > 0) {
const names = filters.creator_ids.map(id => {
const c = creatorsList.find((cr: PaidContentCreator) => cr.id === id)
return c?.username || String(id)
})
result.push({
id: 'creators',
label: 'Creators',
value: filters.creator_ids.join(','),
displayValue: names.length <= 2 ? names.join(', ') : `${names.length} creators`,
onRemove: () => setFilters(prev => ({ ...prev, creator_ids: undefined })),
})
}
if (filters.creator_id) {
const creator = creatorsList.find((c: PaidContentCreator) => c.id === filters.creator_id)
result.push({
id: 'creator',
label: 'Creator',
value: filters.creator_id.toString(),
displayValue: creator?.username || filters.creator_id.toString(),
onRemove: () => handleFilterChange('creator_id', undefined),
})
}
if (filters.content_type) {
result.push({
id: 'content_type',
label: 'Type',
value: filters.content_type,
displayValue: filters.content_type.charAt(0).toUpperCase() + filters.content_type.slice(1) + 's',
onRemove: () => handleFilterChange('content_type', undefined),
})
}
if (filters.tag_id) {
const tag = availableTags.find((t: { id: number; name: string }) => t.id === filters.tag_id)
result.push({
id: 'tag',
label: 'Tag',
value: filters.tag_id.toString(),
displayValue: tag?.name || filters.tag_id.toString(),
onRemove: () => handleFilterChange('tag_id', undefined),
})
}
if (filters.tagged_user) {
result.push({
id: 'tagged_user',
label: 'Tagged',
value: filters.tagged_user,
displayValue: filters.tagged_user === '__none__' ? 'No Tagged Users' : `@${filters.tagged_user}`,
onRemove: () => handleFilterChange('tagged_user', undefined),
})
}
if (filters.min_resolution) {
result.push({
id: 'resolution',
label: 'Resolution',
value: filters.min_resolution,
displayValue: filters.min_resolution + '+',
onRemove: () => handleFilterChange('min_resolution', undefined),
})
}
if (filters.date_from) {
const fromDate = new Date(filters.date_from)
fromDate.setDate(fromDate.getDate() + 7)
result.push({
id: 'date',
label: 'Date',
value: filters.date_from,
displayValue: `${fromDate.toISOString().split('T')[0]} +/-1 week`,
onRemove: () => {
handleFilterChange('date_from', undefined)
handleFilterChange('date_to', undefined)
},
})
}
if (filters.favorites_only) {
result.push({
id: 'favorites',
label: 'Status',
value: 'favorites',
displayValue: 'Favorites',
onRemove: () => handleFilterChange('favorites_only', false),
})
}
if (filters.unviewed_only) {
result.push({
id: 'unviewed',
label: 'Status',
value: 'unviewed',
displayValue: 'Unviewed',
onRemove: () => handleFilterChange('unviewed_only', false),
})
}
if (filters.downloaded_only) {
result.push({
id: 'downloaded',
label: 'Status',
value: 'downloaded',
displayValue: 'Downloaded',
onRemove: () => handleFilterChange('downloaded_only', false),
})
}
if (filters.has_missing) {
result.push({
id: 'missing',
label: 'Status',
value: 'missing',
displayValue: 'Missing Files',
onRemove: () => handleFilterChange('has_missing', false),
})
}
if (filters.missing_description) {
result.push({
id: 'nodesc',
label: 'Status',
value: 'nodesc',
displayValue: 'Missing Description',
onRemove: () => handleFilterChange('missing_description', false),
})
}
if (!filters.hide_empty) {
result.push({
id: 'empty',
label: 'Status',
value: 'empty',
displayValue: 'Empty Posts',
onRemove: () => handleFilterChange('hide_empty', true),
})
}
return result
}, [filters, creatorsList, creatorGroups, availableTags, taggedUsersList, handleFilterChange, setFilters])
// Get completed media attachments for current lightbox post
const lightboxAttachments = lightboxPost?.attachments?.filter(
a => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
) || []
return (
{/* BundleLightbox for gallery view thumbnails — uses flat list across all posts */}
{lightboxPost && viewMode === 'gallery' && galleryFlatAttachments.length > 0 && (
{
setLightboxPost(null)
setLightboxIndex(0)
setSlideshowActive(false)
}}
onNavigate={(idx) => {
setLightboxIndex(idx)
const att = galleryFlatAttachments[idx]
if (att) {
const ownerPost = galleryAttachmentPostMap.get(att.id)
if (ownerPost) setLightboxPost(ownerPost)
}
}}
onViewPost={() => {
const post = lightboxPost
setLightboxPost(null)
setLightboxIndex(0)
if (post) {
if (window.innerWidth < 1024) {
setMobileSelectedPost(post)
} else {
setSelectedPost(post)
}
}
}}
onDelete={async (attachment) => {
if (!lightboxPost) return
if (!confirm('Delete this attachment?')) return
try {
await api.paidContent.deleteAttachment(lightboxPost.id, attachment.id, true)
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
const remaining = galleryFlatAttachments.filter(a => a.id !== attachment.id)
if (remaining.length === 0) {
setLightboxPost(null)
setLightboxIndex(0)
return
}
if (lightboxIndex >= remaining.length) {
setLightboxIndex(remaining.length - 1)
}
} catch (error) {
console.error('Failed to delete attachment:', error)
alert('Failed to delete attachment')
}
}}
/>
)}
{/* BundleLightbox for feed/social view — single post attachments */}
{lightboxPost && viewMode !== 'gallery' && lightboxAttachments.length > 0 && (
{
setLightboxPost(null)
setLightboxIndex(0)
setSlideshowActive(false)
}}
onNavigate={setLightboxIndex}
onViewPost={() => {
const post = lightboxPost
setLightboxPost(null)
setLightboxIndex(0)
if (post) {
if (window.innerWidth < 1024) {
setMobileSelectedPost(post)
} else {
setSelectedPost(post)
}
}
}}
onDelete={async (attachment) => {
if (!lightboxPost) return
if (!confirm('Delete this attachment?')) return
try {
await api.paidContent.deleteAttachment(lightboxPost.id, attachment.id, true)
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
const remaining = lightboxAttachments.filter(a => a.id !== attachment.id)
if (remaining.length === 0) {
setLightboxPost(null)
setLightboxIndex(0)
return
}
if (lightboxIndex >= remaining.length) {
setLightboxIndex(remaining.length - 1)
}
} catch (error) {
console.error('Failed to delete attachment:', error)
alert('Failed to delete attachment')
}
}}
/>
)}
{/* Slideshow BundleLightbox - uses flat attachment list across all posts */}
{slideshowActive && slideshowFlatAttachments.length > 0 && (
{
setSlideshowActive(false)
setSlideshowFlatAttachments([])
setSlideshowAttachmentPostMap(new Map())
setSlideshowPost(null)
setSlideshowIndex(0)
setShuffleEnabled(false)
}}
onNavigate={(idx) => {
setSlideshowIndex(idx)
const att = slideshowFlatAttachments[idx]
if (att) {
const ownerPost = slideshowAttachmentPostMap.get(att.id)
if (ownerPost) setSlideshowPost(ownerPost)
}
}}
initialSlideshow={true}
initialInterval={3000}
totalCount={shuffleEnabled ? shuffleTotal : totalFilteredMedia}
hasMore={shuffleEnabled ? shuffleHasMore : !!hasNextPage}
onLoadMore={shuffleEnabled
? () => { if (!shuffleLoading && shuffleHasMore) fetchShufflePage(shuffleSeed, shuffleOffsetRef.current, false) }
: () => { if (hasNextPage && !isFetchingNextPage) fetchNextPage() }
}
onShuffleChange={(enabled) => {
if (enabled) {
const seed = Math.floor(Math.random() * 1000000)
setShuffleSeed(seed)
setShuffleEnabled(true)
setShuffleTotal(totalFilteredMedia || totalFilteredPosts || 0)
setSlideshowIndex(0)
shuffleOffsetRef.current = 0
fetchShufflePage(seed, 0, true)
} else {
setShuffleEnabled(false)
// Switch back to non-shuffled: rebuild from loaded posts
const flatAttachments: PaidContentAttachment[] = []
const postMap = new Map()
for (const p of posts) {
const completed = (p.attachments || []).filter(
(a: PaidContentAttachment) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
)
for (const att of completed) {
flatAttachments.push(att)
postMap.set(att.id, p)
}
}
setSlideshowFlatAttachments(flatAttachments)
setSlideshowAttachmentPostMap(postMap)
setSlideshowIndex(0)
}
}}
isShuffled={shuffleEnabled}
/>
)}
{/* Header */}
Feed
Browse posts from tracked creators
{/* Slideshow button - gallery view: all items, feed/social view: selected post only */}
{(() => {
if (viewMode === 'gallery') {
const totalMedia = posts?.reduce((sum, p) => sum + ((p.attachments || []).filter((a: PaidContentAttachment) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')).length), 0) || 0
return totalMedia > 1
}
const post = selectedPost || (posts && posts.length > 0 ? posts[0] : null)
const mediaCount = post?.attachments?.filter((a: PaidContentAttachment) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')).length || 0
return mediaCount > 1
})() && (
{
if (viewMode === 'gallery') {
// Gallery view: start backend-driven shuffle slideshow
const seed = Math.floor(Math.random() * 1000000)
setShuffleSeed(seed)
setShuffleEnabled(true)
setShuffleTotal(totalFilteredMedia || totalFilteredPosts || 0)
shuffleOffsetRef.current = 0
fetchShufflePage(seed, 0, true)
setSlideshowIndex(0)
setSlideshowPost(null)
setSlideshowActive(true)
} else {
// Feed/Social view: slideshow the currently selected post only
const post = selectedPost || (posts.length > 0 ? posts[0] : null)
if (post) {
const completed = (post.attachments || []).filter(
(a: PaidContentAttachment) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video')
)
if (completed.length > 0) {
setLightboxPost(post)
setLightboxIndex(0)
setSlideshowActive(true)
}
}
}
}}
className="hidden lg:flex items-center gap-2 px-3 py-2 rounded-lg bg-secondary text-foreground hover:bg-secondary/80 transition-colors"
title="Start Slideshow"
>
Slideshow
)}
{/* Tag Mode button - only show in gallery view */}
{viewMode === 'gallery' && !tagMode && (
setTagMode(true)}
className="hidden lg:flex items-center gap-2 px-3 py-2 rounded-lg bg-secondary text-foreground hover:bg-secondary/80 transition-colors"
>
Tag Mode
)}
{/* View toggle - Social + Gallery */}
{ setViewMode('social'); exitTagMode(); }}
className={`p-2 rounded-md transition-colors ${
viewMode === 'social'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
title="Social view"
>
setViewMode('gallery')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'gallery'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
title="Gallery view"
>
{/* Mobile filter toggle */}
setShowFilters(!showFilters)}
className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-secondary text-foreground"
>
Filters
{activeFilters.length > 0 && (
)}
{/* Filters - Using FilterBar component */}
handleFilterChange('search', value || undefined)}
searchPlaceholder="Search posts..."
filterSections={filterSections}
activeFilters={activeFilters}
onClearAll={clearFilters}
totalCount={viewMode === 'gallery' ? (totalFilteredMedia || totalFilteredPosts) : totalFilteredPosts}
countLabel={viewMode === 'gallery' ? ((totalFilteredMedia || totalFilteredPosts) === 1 ? 'item' : 'items') : (totalFilteredPosts === 1 ? 'post' : 'posts')}
advancedFilters={{
dateFrom: {
value: filters.date_from ? (() => {
const fromDate = new Date(filters.date_from)
fromDate.setDate(fromDate.getDate() + 7)
return fromDate.toISOString().split('T')[0]
})() : '',
onChange: (value) => {
if (value) {
const centerDate = new Date(value)
const fromDate = new Date(centerDate)
const toDate = new Date(centerDate)
fromDate.setDate(centerDate.getDate() - 7)
toDate.setDate(centerDate.getDate() + 7)
handleFilterChange('date_from', fromDate.toISOString().split('T')[0])
handleFilterChange('date_to', toDate.toISOString().split('T')[0])
} else {
handleFilterChange('date_from', undefined)
handleFilterChange('date_to', undefined)
}
},
label: 'Date (+/-1 week)',
},
}}
/>
{/* Unviewed posts banner */}
{unviewedCount > 0 && !filters.unviewed_only && (
{unviewedCount}
{unviewedCount === 1 ? '1 unviewed post' : `${unviewedCount} unviewed posts`}
handleFilterChange('unviewed_only', true)}
className="px-3 py-1.5 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors flex items-center gap-1.5"
>
View unviewed
markAllViewedMutation.mutate()}
disabled={markAllViewedMutation.isPending}
className="px-3 py-1.5 rounded-md text-blue-700 dark:text-blue-300 text-sm hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
>
{markAllViewedMutation.isPending ? 'Marking...' : 'Mark all viewed'}
)}
{/* Active unviewed filter banner */}
{filters.unviewed_only && (
Showing unviewed posts
markAllViewedMutation.mutate()}
disabled={markAllViewedMutation.isPending}
className="px-3 py-1.5 rounded-md text-blue-700 dark:text-blue-300 text-sm font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
>
{markAllViewedMutation.isPending ? 'Marking...' : 'Mark all viewed'}
handleFilterChange('unviewed_only', false)}
className="px-3 py-1.5 rounded-md text-blue-700 dark:text-blue-300 text-sm hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
>
Show all
)}
{/* Unread messages banner */}
{unreadMessagesCount > 0 && (
{unreadMessagesCount}
{unreadMessagesCount === 1 ? '1 unread message' : `${unreadMessagesCount} unread messages`}
View messages
markAllMessagesReadMutation.mutate()}
disabled={markAllMessagesReadMutation.isPending}
className="px-3 py-1.5 rounded-md text-violet-700 dark:text-violet-300 text-sm hover:bg-violet-100 dark:hover:bg-violet-900/30 transition-colors"
>
{markAllMessagesReadMutation.isPending ? 'Marking...' : 'Mark all read'}
)}
{/* Mobile: Full-screen post detail modal */}
{selectedPost && (
setSelectedPost(null)}
onPostUpdated={(updates) => {
if (updates) {
setSelectedPost(prev => prev ? { ...prev, ...updates } : null)
}
}}
/>
)}
{/* Gallery View */}
{viewMode === 'gallery' && (
{/* Tag Mode Header Bar */}
{tagMode && (
{selectedPostIds.size} post{selectedPostIds.size !== 1 ? 's' : ''} selected
Select All ({posts.length})
{selectedPostIds.size > 0 && (
setSelectedPostIds(new Set())}
className="text-sm text-muted-foreground hover:text-foreground"
>
Clear Selection
)}
Exit Tag Mode
)}
{(isLoading || isPending) && (!posts || posts.length === 0) ? (
) : isError && (!posts || posts.length === 0) ? (
Failed to load feed
refetch()} className="mt-2 text-sm text-primary hover:underline">
Tap to retry
) : (!posts || posts.length === 0) ? (
No media found
{hasActiveFilters && (
Clear filters
)}
) : (
<>
{/* Thumbnail grid - all attachments from all posts (pinned first, then regular - same as social view) */}
{[...pinnedPosts, ...regularPosts].flatMap((post) =>
(post.attachments || [])
.filter((att: PaidContentAttachment) => att.status === 'completed' && (att.file_type === 'image' || att.file_type === 'video'))
.map((att: PaidContentAttachment) => {
const _pfExts = ['jpg', 'jpeg', 'png', 'webp', 'gif']
const _isPF = att.file_type === 'video' && _pfExts.includes(att.extension?.toLowerCase() || '')
const isVideo = att.file_type === 'video' && !_isPF
const isPostSelected = selectedPostIds.has(post.id)
return (
{
if (tagMode) {
// In tag mode, toggle post selection
togglePostSelection(post.id)
} else {
// Normal mode - open lightbox with all gallery attachments
const flatIdx = galleryFlatAttachments.findIndex((a: PaidContentAttachment) => a.id === att.id)
setLightboxPost(post)
setLightboxIndex(flatIdx >= 0 ? flatIdx : 0)
}
}}
>
{
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
{/* Selection checkbox overlay in tag mode */}
{tagMode && (
{isPostSelected ? (
) : (
)}
)}
{/* Video indicator */}
{isVideo && !tagMode && (
)}
{/* Duration badge for videos */}
{isVideo && att.duration && (
{Math.floor(att.duration / 60)}:{Math.floor(att.duration % 60).toString().padStart(2, '0')}
)}
{/* Lock overlay for DRM preview frames */}
{_isPF && !tagMode && (
)}
{/* Creator/date info on hover - hide in tag mode */}
{!tagMode && (
{post.display_name || post.username}
@{post.username} · {post.published_at ? new Date(post.published_at).toLocaleDateString() : 'Unknown'}
)}
)
})
)}
{/* Infinite scroll trigger */}
{isFetchingNextPage ? (
Loading more...
) : hasNextPage ? (
Scroll for more
) : (posts && posts.length > 0) ? (
End of gallery
) : null}
>
)}
{/* Tag Selection Bar - Bottom of gallery in tag mode */}
{tagMode && (
Select tags to apply:
{selectedTagIds.size > 0 && (
{selectedTagIds.size} tag{selectedTagIds.size !== 1 ? 's' : ''} selected
)}
{availableTags.map((tag) => {
const isSelected = selectedTagIds.has(tag.id)
return (
toggleTagSelection(tag.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all border-2 ${
isSelected
? 'shadow-md'
: 'opacity-60 hover:opacity-100'
}`}
style={{
backgroundColor: isSelected ? tag.color : `${tag.color}20`,
color: isSelected ? '#fff' : tag.color,
borderColor: tag.color,
}}
>
{tag.name}
)
})}
Cancel
{isFeatureEnabled('/private-gallery') && (
setShowCopyToGalleryModal(true)}
disabled={selectedPostIds.size === 0}
className="px-4 py-2 rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
Copy to Private ({selectedPostIds.size})
)}
{applyTagsMutation.isPending ? (
<>
Applying...
>
) : (
<>
Apply Tags ({selectedPostIds.size} post{selectedPostIds.size !== 1 ? 's' : ''})
>
)}
)}
)}
{/* Social View - Desktop only (Fansly-style) */}
{viewMode === 'social' && (
<>
{/* BundleLightbox for social view */}
{socialLightboxPost && (
a.status === 'completed' && a.file_type !== 'audio') || []}
currentIndex={socialLightboxIndex}
startTime={socialLightboxStartTime}
wasPlaying={socialLightboxWasPlaying}
onClose={() => {
setSocialLightboxPost(null)
setSocialLightboxIndex(0)
setSocialLightboxStartTime(undefined)
setSocialLightboxWasPlaying(false)
}}
onCloseWithVideoState={(attachmentId, time, wasPlaying) => {
// Pass video state back to PostDetailView
setVideoReturnState({ attachmentId, time, wasPlaying })
setSocialLightboxPost(null)
setSocialLightboxIndex(0)
setSocialLightboxStartTime(undefined)
setSocialLightboxWasPlaying(false)
}}
onNavigate={(index) => {
setSocialLightboxIndex(index)
setSocialLightboxStartTime(undefined) // Clear startTime when navigating to new item
}}
onViewPost={() => {
const post = socialLightboxPost
setSocialLightboxPost(null)
setSocialLightboxIndex(0)
setSocialLightboxStartTime(undefined)
setSocialLightboxWasPlaying(false)
if (post) {
if (window.innerWidth < 1024) {
setMobileSelectedPost(post)
} else {
setSelectedPost(post)
}
}
}}
onToggleFavorite={async () => {
await api.paidContent.toggleFavorite(socialLightboxPost.id)
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
// Update local state to reflect change
setSocialLightboxPost(prev => prev ? { ...prev, is_favorited: prev.is_favorited ? 0 : 1 } : null)
}}
onDelete={async (attachment) => {
if (!confirm('Delete this attachment?')) return
try {
await api.paidContent.deleteAttachment(socialLightboxPost.id, attachment.id, true)
const newAttachments = (socialLightboxPost.attachments || []).filter(a => a.id !== attachment.id)
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
// If no completed visual attachments left, close lightbox
const remaining = newAttachments.filter(a => a.status === 'completed' && a.file_type !== 'audio')
if (remaining.length === 0) {
setSocialLightboxPost(null)
setSocialLightboxIndex(0)
return
}
// Adjust index if needed
setSocialLightboxPost({ ...socialLightboxPost, attachments: newAttachments })
if (socialLightboxIndex >= remaining.length) {
setSocialLightboxIndex(remaining.length - 1)
}
} catch (error) {
console.error('Failed to delete attachment:', error)
alert('Failed to delete attachment')
}
}}
/>
)}
{/* Post List - Left sidebar (narrower in social view) */}
{(isLoading || isPending) && (!posts || posts.length === 0) ? (
) : isError && (!posts || posts.length === 0) ? (
Failed to load feed
refetch()} className="mt-2 text-sm text-primary hover:underline">
Tap to retry
) : (!posts || posts.length === 0) ? (
No posts found
{hasActiveFilters && (
Clear filters
)}
) : (
<>
{/* Pinned posts - collapsible */}
{pinnedPosts.length > 0 && (
setPinnedCollapsed(!pinnedCollapsed)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-amber-500 hover:bg-accent/50"
>
Pinned ({pinnedPosts.length})
{!pinnedCollapsed && pinnedPosts.map(post => (
setSelectedPost(post)}
onOpenLightbox={(attachmentIndex) => {
setSocialLightboxPost(post)
setSocialLightboxIndex(attachmentIndex)
}}
onCopyToGallery={isFeatureEnabled('/private-gallery') ? setCopyPost : undefined}
/>
))}
)}
{/* Regular posts */}
{regularPosts.map((post) => (
setSelectedPost(post)}
onOpenLightbox={(attachmentIndex) => {
setSocialLightboxPost(post)
setSocialLightboxIndex(attachmentIndex)
}}
onCopyToGallery={isFeatureEnabled('/private-gallery') ? setCopyPost : undefined}
/>
))}
{/* Infinite scroll trigger */}
{isFetchingNextPage ? (
Loading more...
) : hasNextPage ? (
Scroll for more
) : (posts && posts.length > 0) ? (
No more posts
) : null}
>
)}
{/* Fansly-style Post Detail - Right panel */}
{selectedPost ? (
setSelectedPost(null)}
onPostUpdated={(updates) => {
if (updates) {
setSelectedPost(prev => prev ? { ...prev, ...updates } : null)
}
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
}}
onOpenLightbox={(attachmentIndex, _isVideo, startTime, wasPlaying) => {
setSocialLightboxPost(selectedPost)
setSocialLightboxIndex(attachmentIndex)
setSocialLightboxStartTime(startTime)
setSocialLightboxWasPlaying(wasPlaying ?? false)
}}
videoReturnState={videoReturnState}
onVideoReturnStateHandled={() => setVideoReturnState(null)}
onCopyToGallery={isFeatureEnabled('/private-gallery') && selectedPost.attachments?.some((a: PaidContentAttachment) => a.status === 'completed' && a.local_path) ? () => setCopyPost(selectedPost) : undefined}
/>
) : (
Select a post to view details
)}
{/* Mobile - Continuous scroll of full posts (like Instagram) */}
{(isLoading || isPending) && (!posts || posts.length === 0) ? (
) : isError && (!posts || posts.length === 0) ? (
Failed to load feed
refetch()} className="mt-2 text-sm text-primary hover:underline">
Tap to retry
) : (!posts || posts.length === 0) ? (
) : (
<>
{/* Pinned posts - collapsible (mobile) */}
{pinnedPosts.length > 0 && (
setPinnedCollapsed(!pinnedCollapsed)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-amber-500 hover:bg-accent/50"
>
Pinned ({pinnedPosts.length})
{!pinnedCollapsed && pinnedPosts.map(post => (
setMobileSelectedPost(post)}
>
{
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
}}
onOpenLightbox={(attachmentIndex, _isVideo, startTime, wasPlaying) => {
setSocialLightboxPost(post)
setSocialLightboxIndex(attachmentIndex)
setSocialLightboxStartTime(startTime)
setSocialLightboxWasPlaying(wasPlaying ?? false)
setMobileSelectedPost(null)
}}
videoReturnState={videoReturnState?.attachmentId && post.attachments?.some((a: PaidContentAttachment) => a.id === videoReturnState.attachmentId) ? videoReturnState : null}
onVideoReturnStateHandled={() => setVideoReturnState(null)}
compactMode={true}
onCopyToGallery={isFeatureEnabled('/private-gallery') && post.attachments?.some((a: PaidContentAttachment) => a.status === 'completed' && a.local_path) ? () => setCopyPost(post) : undefined}
/>
))}
)}
{/* Regular posts (mobile) */}
{regularPosts.map((post) => (
setMobileSelectedPost(post)}
>
{
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
}}
onOpenLightbox={(attachmentIndex, _isVideo, startTime, wasPlaying) => {
setSocialLightboxPost(post)
setSocialLightboxIndex(attachmentIndex)
setSocialLightboxStartTime(startTime)
setSocialLightboxWasPlaying(wasPlaying ?? false)
setMobileSelectedPost(null)
}}
videoReturnState={videoReturnState?.attachmentId && post.attachments?.some((a: PaidContentAttachment) => a.id === videoReturnState.attachmentId) ? videoReturnState : null}
onVideoReturnStateHandled={() => setVideoReturnState(null)}
compactMode={true}
onCopyToGallery={isFeatureEnabled('/private-gallery') && post.attachments?.some((a: PaidContentAttachment) => a.status === 'completed' && a.local_path) ? () => setCopyPost(post) : undefined}
/>
))}
{/* Infinite scroll trigger - mobile only (always social view) */}
{isFetchingNextPage ? (
Loading more...
) : hasNextPage ? (
Scroll for more
) : (posts && posts.length > 0) ? (
No more posts
) : null}
>
)}
{/* Mobile Full Post Modal (hidden when lightbox is open) */}
{mobileSelectedPost && !socialLightboxPost && (
{/* Header with back button */}
setMobileSelectedPost(null)}
className="p-2 -ml-2 hover:bg-secondary rounded-lg"
>
Post
{/* Full post view */}
{
queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] })
}}
onOpenLightbox={(attachmentIndex, _isVideo, startTime, wasPlaying) => {
setSocialLightboxPost(mobileSelectedPost)
setSocialLightboxIndex(attachmentIndex)
setSocialLightboxStartTime(startTime)
setSocialLightboxWasPlaying(wasPlaying ?? false)
setMobileSelectedPost(null) // Close modal so lightbox shows on top
}}
videoReturnState={videoReturnState}
onVideoReturnStateHandled={() => setVideoReturnState(null)}
compactMode={false}
onCopyToGallery={isFeatureEnabled('/private-gallery') && mobileSelectedPost.attachments?.some((a: PaidContentAttachment) => a.status === 'completed' && a.local_path) ? () => setCopyPost(mobileSelectedPost) : undefined}
/>
)}
>
)}
{/* Desktop: Split View / Mobile: Full-width post list (Feed view) */}
{/* Only show when feed view is active */}
{viewMode === 'feed' && (
{/* Post List - Full width on mobile, sidebar on desktop */}
{(isLoading || isPending) && (!posts || posts.length === 0) ? (
) : isError && (!posts || posts.length === 0) ? (
Failed to load feed
refetch()} className="mt-2 text-sm text-primary hover:underline">
Tap to retry
) : (!posts || posts.length === 0) ? (
No posts found
{hasActiveFilters && (
Clear filters
)}
) : (
<>
{/* Pinned posts - collapsible */}
{pinnedPosts.length > 0 && (
setPinnedCollapsed(!pinnedCollapsed)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-amber-500 hover:bg-accent/50"
>
Pinned ({pinnedPosts.length})
{!pinnedCollapsed && pinnedPosts.map(post => (
setSelectedPost(post)}
onOpenLightbox={(attachmentIndex) => {
setLightboxPost(post)
setLightboxIndex(attachmentIndex)
}}
onCopyToGallery={isFeatureEnabled('/private-gallery') ? setCopyPost : undefined}
/>
))}
)}
{/* Regular posts */}
{regularPosts.map((post) => (
setSelectedPost(post)}
onOpenLightbox={(attachmentIndex) => {
setLightboxPost(post)
setLightboxIndex(attachmentIndex)
}}
onCopyToGallery={isFeatureEnabled('/private-gallery') ? setCopyPost : undefined}
/>
))}
{/* Infinite scroll trigger */}
{isFetchingNextPage ? (
Loading more...
) : hasNextPage ? (
Scroll for more
) : (posts && posts.length > 0) ? (
No more posts
) : null}
>
)}
{/* Desktop Post Detail - hidden on mobile (uses modal above) */}
{selectedPost && (
setSelectedPost(null)}
onPostUpdated={(updates) => {
if (updates) {
setSelectedPost(prev => prev ? { ...prev, ...updates } : null)
}
}}
/>
)}
)}
{/* Copy to Private Gallery Modal (bulk - tag mode) */}
setShowCopyToGalleryModal(false)}
sourcePaths={
// Get all local_path from completed attachments of selected posts
posts
.filter((p: PaidContentPost) => selectedPostIds.has(p.id))
.flatMap((p: PaidContentPost) => (p.attachments || [])
.filter((a: PaidContentAttachment) => a.status === 'completed' && a.local_path)
.map((a: PaidContentAttachment) => a.local_path!))
}
sourceType="paid_content"
onSuccess={() => {
setShowCopyToGalleryModal(false)
setSelectedPostIds(new Set())
setTagMode(false)
}}
/>
{/* Copy to Private Gallery Modal (single post) */}
{copyPost && (
setCopyPost(null)}
sourcePaths={
(copyPost.attachments || [])
.filter((a: PaidContentAttachment) => a.status === 'completed' && a.local_path)
.map((a: PaidContentAttachment) => a.local_path!)
}
sourceNames={
Object.fromEntries(
(copyPost.attachments || [])
.filter((a: PaidContentAttachment) => a.status === 'completed' && a.local_path)
.map((a: PaidContentAttachment) => [a.local_path!, a.name || a.local_path!.split('/').pop() || a.local_path!])
)
}
sourceType="paid_content"
onSuccess={() => setCopyPost(null)}
/>
)}
)
}