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

4110 lines
170 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
? `/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 (
<div
className={`${isVideo ? 'aspect-video' : 'aspect-square'} bg-secondary rounded-lg overflow-hidden relative group ${
attachment.status === 'completed' ? 'cursor-pointer hover:ring-2 hover:ring-primary' : ''
}`}
onClick={handleClick}
title={metadata.join(' • ')}
>
{canShowThumbnail ? (
// Use cached thumbnail for both images and videos
<img
src={`/api/paid-content/files/thumbnail/${attachment.id}?size=large&${thumbCacheBuster}`}
alt={attachment.name}
className="w-full h-full object-cover"
loading="lazy"
onError={() => setThumbnailError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
{isImage && <ImageIcon className="w-8 h-8 text-muted-foreground" />}
{isVideo && <Video className="w-8 h-8 text-muted-foreground" />}
{!isImage && !isVideo && <File className="w-8 h-8 text-muted-foreground" />}
</div>
)}
{/* Metadata overlay on hover */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-white text-xs truncate">
{attachment.name || `File ${attachment.attachment_index + 1}`}
</div>
<div className="text-white/70 text-xs flex items-center space-x-2">
{attachment.file_size && <span>{formatFileSize(attachment.file_size)}</span>}
{attachment.width && attachment.height && <span>{attachment.width}×{attachment.height}</span>}
{isVideo && attachment.duration && <span>{formatDuration(attachment.duration)}</span>}
</div>
</div>
{/* Status indicators */}
{attachment.status === 'completed' && (
<div className="absolute top-1 right-1 bg-emerald-500 text-white rounded-full p-1">
<Download className="w-3 h-3" />
</div>
)}
{attachment.status === 'pending' && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-xs text-white">Pending</span>
</div>
)}
{attachment.status === 'failed' && (
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
<span className="text-xs text-white">Failed</span>
</div>
)}
{isVideo && attachment.status === 'completed' && (
<div className="absolute top-1 left-1 bg-black/60 text-white rounded px-1.5 py-0.5 text-xs">
<Video className="w-3 h-3 inline mr-1" />
{attachment.duration ? formatDuration(attachment.duration) : 'Video'}
</div>
)}
{isPF && attachment.status === 'completed' && (
<div className="absolute inset-0 bg-black/20 flex items-center justify-center">
<Lock className="w-6 h-6 text-white/70" />
</div>
)}
{/* Import button for failed/pending attachments */}
{isMissing && onImport && (
<button
onClick={(e) => {
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"
>
<Link className="w-3 h-3" />
</button>
)}
</div>
)
}
// Import Modal for importing files from URLs or uploading directly
function ImportUrlModal({
attachment,
onClose,
onSuccess
}: {
attachment: PaidContentAttachment
onClose: () => void
onSuccess: () => void | Promise<void>
}) {
const [mode, setMode] = useState<'url' | 'file'>('url')
const [url, setUrl] = useState('')
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [progress, setProgress] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={isDownloading || isLoading ? undefined : onClose}>
<div
className="bg-card border border-border rounded-xl shadow-xl w-full max-w-md mx-4 p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-foreground">
Import Attachment
</h3>
{!isDownloading && !isLoading && (
<button onClick={onClose} className="p-1 rounded-lg hover:bg-secondary transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
)}
</div>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Import file for: <span className="font-medium text-foreground">{attachment.name || `Attachment ${attachment.attachment_index + 1}`}</span>
</p>
{/* Mode tabs */}
<div className="flex border border-border rounded-lg overflow-hidden">
<button
onClick={() => { 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'
}`}
>
<Link className="w-4 h-4 inline mr-2" />
From URL
</button>
<button
onClick={() => { 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'
}`}
>
<Plus className="w-4 h-4 inline mr-2" />
Upload File
</button>
</div>
{/* URL input */}
{mode === 'url' && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Supported: Bunkr, Pixeldrain, Gofile, Cyberdrop, or direct file URLs
</p>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://..."
className="w-full px-4 py-2 rounded-lg border border-border bg-secondary text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
disabled={isDownloading || isLoading}
onKeyDown={(e) => {
if (e.key === 'Enter' && !isLoading && !isDownloading) {
handleImportUrl()
}
}}
/>
</div>
)}
{/* File upload */}
{mode === 'file' && (
<div className="space-y-2">
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !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 ? (
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">{selectedFile.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(selectedFile.size)}</p>
<p className="text-xs text-green-500">Click to change file</p>
</div>
) : (
<div className="space-y-1">
<Plus className="w-8 h-8 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Drop file here or click to browse
</p>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
setSelectedFile(file)
setError(null)
}
}}
/>
</div>
)}
{/* Progress display */}
{(isDownloading || (isLoading && mode === 'file')) && progress && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-blue-500">
<Loader2 className="w-4 h-4 animate-spin" />
{progress}
</div>
{progress.includes('%') && (
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: progress.match(/(\d+)%/)?.[1] + '%' || '0%' }}
/>
</div>
)}
</div>
)}
{error && (
<div className="flex items-center gap-2 text-sm text-red-500">
<AlertTriangle className="w-4 h-4" />
{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg border border-border text-foreground hover:bg-secondary transition-colors"
disabled={isLoading || isDownloading}
>
{isDownloading ? 'Downloading...' : 'Cancel'}
</button>
<button
onClick={mode === 'url' ? handleImportUrl : handleUploadFile}
disabled={isLoading || isDownloading || (mode === 'url' ? !url.trim() : !selectedFile)}
className="px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{mode === 'url' ? 'Starting...' : 'Uploading...'}
</>
) : (
<>
<Download className="w-4 h-4" />
{mode === 'url' ? 'Import' : 'Upload'}
</>
)}
</button>
</div>
</div>
</div>
</div>
)
}
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 (
<div
data-post-id={post.id}
className={`p-4 border-b border-border cursor-pointer hover:bg-muted/50 transition-colors ${isSelected && post.is_pinned === 1 ? 'bg-amber-100/80 dark:bg-amber-900/30' : isSelected ? 'bg-primary/10' : post.is_pinned === 1 ? 'bg-amber-50/60 dark:bg-amber-900/15' : ''}`}
onClick={() => {
// Only handle click on desktop (no touch events)
if (!('ontouchstart' in window)) {
onClick()
}
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Header row */}
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-violet-500 p-0.5 flex-shrink-0">
<div className="w-full h-full rounded-full overflow-hidden bg-card">
{post.profile_image_url ? (
<img src={post.profile_image_url} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-secondary">
<User className="w-5 h-5 text-muted-foreground" />
</div>
)}
</div>
</div>
<div>
<p className="font-medium text-foreground">{post.display_name || post.username}</p>
<p className="text-xs text-muted-foreground">@{post.username}</p>
</div>
</div>
<div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
{post.is_pinned === 1 && (
<span className="text-amber-500" title="Pinned">
<Pin className="w-4 h-4" />
</span>
)}
{onCopyToGallery && post.attachments?.some(a => a.status === 'completed' && a.local_path) && (
<button
onClick={(e) => {
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"
>
<ImagePlus className="w-4 h-4" />
</button>
)}
{firstCompletedAttachment && (
<button
onClick={(e) => {
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'}
>
<Clock className={`w-4 h-4 ${hasWatchLaterItems ? 'fill-current' : ''}`} />
</button>
)}
<button
onClick={(e) => {
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'
}`}
>
<Heart className={`w-4 h-4 ${post.is_favorited ? 'fill-current' : ''}`} />
</button>
<button
onClick={(e) => {
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 ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4 text-primary" />}
</button>
</div>
</div>
{/* Tags row */}
{(post.tags?.length || previewFrameIds.size > 0 || post.tags?.some(t => t.slug === 'ppv' || t.name === 'PPV')) ? (
<div className="flex flex-wrap gap-1 mt-2 ml-[3.25rem]">
{/* PPV tags first */}
{(post.tags || []).filter(t => t.slug === 'ppv' || t.name === 'PPV').map((tag) => (
<span
key={tag.id}
className="px-2 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: tag.color, color: '#fff' }}
>
{tag.name}
</span>
))}
{/* DRM badge second */}
{previewFrameIds.size > 0 && (
<span className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/15 text-amber-600 dark:text-amber-400">
<Lock className="w-2.5 h-2.5" />
DRM
</span>
)}
{/* Remaining tags */}
{(post.tags || []).filter(t => t.slug !== 'ppv' && t.name !== 'PPV').map((tag) => (
<span
key={tag.id}
className="px-2 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: tag.color, color: '#fff' }}
>
{tag.name}
</span>
))}
</div>
) : 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 ? (
<div className="mt-3">
<p className="text-sm text-muted-foreground line-clamp-3">{cleanCaption(decodeHtmlEntities(displayText))}</p>
</div>
) : null
})()}
{/* PPV Locked Attachments - placeholder grid (mobile only) */}
{unavailableAttachments.length > 0 && completedAttachments.length === 0 && (
<div className="mt-3 lg:hidden">
<div className={`grid gap-1 rounded-lg overflow-hidden ${
unavailableAttachments.length === 1 ? 'grid-cols-1' :
'grid-cols-2'
}`}>
{unavailableAttachments.slice(0, 4).map((att) => (
<div
key={att.id}
className={`relative bg-secondary/50 border border-dashed border-muted-foreground/20 overflow-hidden ${
unavailableAttachments.length === 1 ? 'aspect-video' : 'aspect-square'
} flex flex-col items-center justify-center gap-1`}
>
<Lock className="w-5 h-5 text-muted-foreground/40" />
<span className="text-[10px] text-muted-foreground/50">
{att.file_type === 'video' ? 'Video' : att.file_type === 'image' ? 'Image' : 'Media'} locked
</span>
</div>
))}
</div>
</div>
)}
{/* Attachment Grid - Inline thumbnails (mobile only, desktop uses detail panel) */}
{visualAttachments.length > 0 && (
<div className="mt-3 lg:hidden">
<div className={`grid gap-1 rounded-lg overflow-hidden ${
visualAttachments.length === 1 ? 'grid-cols-1' :
visualAttachments.length === 2 ? 'grid-cols-2' :
'grid-cols-2'
}`}>
{visualAttachments.slice(0, 4).map((att, idx) => {
const isImage = att.file_type === 'image'
const isVideo = att.file_type === 'video'
return (
<button
key={att.id}
type="button"
className={`relative bg-secondary overflow-hidden cursor-pointer active:opacity-70 ${
visualAttachments.length === 1 ? 'aspect-video' : 'aspect-square'
}`}
style={{ touchAction: 'manipulation', WebkitTapHighlightColor: 'transparent' }}
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
if (onOpenLightbox) {
onOpenLightbox(idx)
}
}}
>
{(isImage || isVideo) && att.id ? (
// Use cached thumbnail from database
<div className="w-full h-full bg-slate-800 relative pointer-events-none">
<img
src={`/api/paid-content/files/thumbnail/${att.id}?size=large&${att.file_hash ? `v=${att.file_hash.slice(0, 8)}` : THUMB_CACHE_V}`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
// Hide broken image, show fallback
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
{/* Lock overlay for video preview frames (PPV/DRM) */}
{previewFrameIds.has(att.id) ? (
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
<Lock className="w-8 h-8 text-white/80" />
</div>
) : (
<>
{/* Play button overlay for videos */}
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-black/50 flex items-center justify-center">
<Video className="w-6 h-6 text-white" />
</div>
</div>
)}
{/* Duration badge for videos */}
{isVideo && att.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded">
{Math.floor(att.duration / 60)}:{Math.floor(att.duration % 60).toString().padStart(2, '0')}
</div>
)}
</>
)}
</div>
) : (
<div className="w-full h-full flex items-center justify-center pointer-events-none">
<File className="w-8 h-8 text-muted-foreground" />
</div>
)}
{/* Show +N overlay on the 4th item if there are more */}
{idx === 3 && hasMoreAttachments && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center pointer-events-none">
<span className="text-white text-xl font-bold">
+{(post.attachment_count || 0) - 4}
</span>
</div>
)}
</button>
)
})}
</div>
</div>
)}
{/* Inline Audio Player - mobile only, prevents tap from opening post */}
{completedAttachments.some(a => a.file_type === 'audio') && (
<div
className="mt-3 lg:hidden space-y-2"
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
{completedAttachments.filter(a => a.file_type === 'audio').map((audio) => {
const audioUrl = audio.local_path
? `/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 (
<div key={audio.id} className="rounded-lg border border-border bg-card/50 p-2.5">
<div className="flex items-center gap-2 mb-1.5">
<div className="flex-shrink-0 w-8 h-8 rounded-md bg-primary/10 flex items-center justify-center">
<Music className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-foreground truncate">{audio.name || 'Audio'}</div>
{fileSizeMB && <div className="text-[10px] text-muted-foreground">{fileSizeMB} MB</div>}
</div>
</div>
<audio
src={audioUrl || undefined}
controls
preload="metadata"
className="w-full h-8"
style={{ colorScheme: 'dark' }}
/>
</div>
)
})}
</div>
)}
{/* Footer */}
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="flex items-center space-x-1">
<Calendar className="w-3 h-3" />
<span>{post.published_at ? formatRelativeTime(post.published_at) : 'Unknown date'}</span>
</span>
{previewFrameIds.size > 0 && post.post_id && (post.service_id === 'onlyfans_direct' || post.platform === 'onlyfans') && (
<a
href={`https://onlyfans.com/${post.post_id}/${post.username}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-0.5 text-primary hover:underline"
title="View this post on OnlyFans"
>
<ExternalLink className="w-3 h-3" />
<span>OF</span>
</a>
)}
{previewFrameIds.size > 0 && post.post_id && (post.service_id === 'fansly_direct' || post.platform === 'fansly') && (
<a
href={`https://fansly.com/post/${post.post_id}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-0.5 text-primary hover:underline"
title="View this post on Fansly"
>
<ExternalLink className="w-3 h-3" />
<span>Fansly</span>
</a>
)}
</div>
<div className="flex items-center space-x-2">
{/* 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 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
<ImageIcon className="w-3 h-3" />
<span>{imageCount}</span>
</span>
)}
{videoCount > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
<Video className="w-3 h-3" />
<span>{videoCount}</span>
</span>
)}
{audioCount > 0 && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
<Music className="w-3 h-3" />
<span>{audioCount}</span>
</span>
)}
</>
)
})()}
{post.downloaded && (
<span className="flex items-center space-x-1 text-emerald-600 dark:text-emerald-400">
<Download className="w-3 h-3" />
</span>
)}
</div>
</div>
</div>
)
}
function PostDetail({ post, onClose, onPostUpdated }: { post: PaidContentPost; onClose: () => void; onPostUpdated?: (updatedPost?: Partial<PaidContentPost>) => void }) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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<number[]>(post.tags?.map(t => t.id) || [])
const [importAttachment, setImportAttachment] = useState<PaidContentAttachment | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [deletingAttachmentId, setDeletingAttachmentId] = useState<number | null>(null)
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null)
// Selection mode for batch delete
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
const lastSelectedAttIdx = useRef<number | null>(null)
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="h-full flex flex-col">
{/* BundleLightbox */}
{lightboxIndex !== null && completedAttachments.length > 0 && (
<BundleLightbox
post={post}
attachments={completedAttachments}
currentIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
onDelete={handleLightboxDelete}
/>
)}
{/* Header */}
<div className="p-4 border-b border-border flex items-center justify-between sticky top-0 bg-card z-10">
{/* Mobile back button */}
<button
type="button"
onClick={onClose}
onTouchEnd={(e) => { 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' }}
>
<ChevronLeft className="w-5 h-5" />
<span className="text-sm font-medium">Back</span>
</button>
<div className="flex items-center space-x-3 lg:flex-1">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-violet-500 p-0.5 flex-shrink-0">
<div className="w-full h-full rounded-full overflow-hidden bg-card">
{post.profile_image_url ? (
<img src={post.profile_image_url} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-secondary">
<User className="w-5 h-5 text-muted-foreground" />
</div>
)}
</div>
</div>
<div className="hidden lg:block">
<p className="font-medium text-foreground">{post.display_name || post.username}</p>
<p className="text-xs text-muted-foreground capitalize">
{post.platform} / {post.published_at ? formatRelativeTime(post.published_at) : 'Unknown date'}
</p>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button
type="button"
onClick={(e) => {
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'}
>
<CheckSquare className="w-5 h-5" />
</button>
<button
onClick={() => setIsEditing(false)}
className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground"
title="Cancel"
>
<X className="w-5 h-5" />
</button>
<button
onClick={handleSave}
disabled={updateMutation.isPending || tagsMutation.isPending}
className="p-2 rounded-lg hover:bg-secondary transition-colors text-primary"
title="Save"
>
{updateMutation.isPending || tagsMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Save className="w-5 h-5" />
)}
</button>
</>
) : (
<>
<button
type="button"
onClick={(e) => {
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'}
>
<CheckSquare className="w-5 h-5" />
</button>
<button
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground"
title="Refresh from source"
>
{refreshMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<RefreshCw className="w-5 h-5" />
)}
</button>
<button
onClick={() => setIsEditing(true)}
className="p-2 rounded-lg hover:bg-secondary transition-colors text-muted-foreground"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
type="button"
onClick={async (e) => {
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"
>
<Trash2 className="w-5 h-5 text-destructive" />
</button>
{/* Desktop close button */}
<button onClick={onClose} className="hidden lg:block p-2 rounded-lg hover:bg-secondary transition-colors">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{isEditing ? (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Title</label>
<input
type="text"
value={editTitle}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Date</label>
<input
type="date"
value={editDate}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">Description</label>
<textarea
value={editContent}
onChange={(e) => setEditContent(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 min-h-[200px] resize-y"
placeholder="Post description"
/>
</div>
<div className="space-y-2">
<TagSearchSelector
tags={availableTags}
selectedTagIds={editTags}
onChange={setEditTags}
label="Tags"
/>
</div>
</>
) : (
<>
<h2 className="text-lg font-semibold text-foreground">{getPostDisplayTitle(post)}</h2>
{/* Display tags in view mode */}
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<span
key={tag.id}
className="px-2.5 py-1 rounded-full text-xs font-medium"
style={{ backgroundColor: tag.color, color: '#fff' }}
>
{tag.name}
</span>
))}
</div>
)}
{post.content && (
<p className="text-muted-foreground whitespace-pre-wrap">{cleanCaption(decodeHtmlEntities(post.content))}</p>
)}
</>
)}
{/* Attachments Grid */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-foreground">
Attachments ({post.attachments?.length || 0})
</h3>
{isEditing && (
<>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileUpload}
className="hidden"
accept="image/*,video/*,audio/*"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
Upload
</button>
</>
)}
</div>
{/* Batch delete bar - shown when items are selected */}
{selectionMode && selectedIds.size > 0 && (
<div className="p-3 bg-secondary/50 border border-border rounded-lg flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
<button
onClick={() => {
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'}
</button>
</div>
<button
onClick={handleBatchDelete}
disabled={isBatchDeleting}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500 text-white text-sm font-medium rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
>
{isBatchDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</button>
</div>
)}
{post.attachments && post.attachments.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{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 (
<div key={attachment.id} className={`relative group aspect-square ${selectionMode && isSelected ? 'ring-2 ring-primary ring-offset-1 ring-offset-background rounded-lg' : ''}`}>
<div className="w-full h-full">
<AttachmentThumbnail
attachment={attachment}
onClick={selectionMode ? () => handleAttSelection(new MouseEvent('click') as unknown as React.MouseEvent) : isEditing ? undefined : () => openLightbox(attachment)}
onImport={(att) => setImportAttachment(att)}
/>
</div>
{/* Selection checkbox */}
{selectionMode && (
<button
onClick={(e) => {
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 && <Check className="w-4 h-4" />}
</button>
)}
{/* Delete button - only in edit mode */}
{isEditing && !selectionMode && !isConfirming && !isDeleting && (
<button
onClick={(e) => {
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"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{/* Confirmation overlay */}
{isConfirming && (
<div className="absolute inset-0 bg-black/80 rounded-lg flex flex-col items-center justify-center gap-2 p-2 z-10">
<AlertTriangle className="w-5 h-5 text-yellow-500" />
<span className="text-xs text-white text-center">Delete?</span>
<div className="flex gap-1">
<button
onClick={() => handleDeleteAttachment(attachment.id)}
className="px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600"
>
Yes
</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="px-2 py-1 text-xs bg-secondary text-foreground rounded hover:bg-secondary/80"
>
No
</button>
</div>
</div>
)}
{/* Deleting spinner overlay */}
{isDeleting && (
<div className="absolute inset-0 bg-black/80 rounded-lg flex items-center justify-center z-10">
<Loader2 className="w-6 h-6 text-white animate-spin" />
</div>
)}
</div>
)
})}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<File className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No attachments</p>
{isEditing && (
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="mt-2 text-primary hover:underline text-sm"
>
Click to upload
</button>
)}
</div>
)}
{/* Batch delete bar (bottom) */}
{selectionMode && selectedIds.size > 0 && (
<div className="p-3 bg-secondary/50 border border-border rounded-lg flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{selectedIds.size} selected
</span>
<button
onClick={() => {
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'}
</button>
</div>
<button
onClick={handleBatchDelete}
disabled={isBatchDeleting}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500 text-white text-sm font-medium rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
>
{isBatchDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</button>
</div>
)}
</div>
</div>
{/* Import URL Modal */}
{importAttachment && (
<ImportUrlModal
attachment={importAttachment}
onClose={() => 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 })
}}
/>
)}
</div>
)
}
// 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<PaidContentPost | null>(null)
// Default to 'social' view on all devices
const [viewMode, setViewMode] = useState<'feed' | 'gallery' | 'social'>('social')
// Social view lightbox state
const [socialLightboxPost, setSocialLightboxPost] = useState<PaidContentPost | null>(null)
const [socialLightboxIndex, setSocialLightboxIndex] = useState(0)
const [socialLightboxStartTime, setSocialLightboxStartTime] = useState<number | undefined>(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<PaidContentPost | null>(null)
// Pinned posts collapsible section state (starts collapsed)
const [pinnedCollapsed, setPinnedCollapsed] = useState(true)
// Initialize filters from URL params
const [filters, setFilters] = useState<FeedFilters>(() => 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<HTMLDivElement>(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<string | null>(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<PaidContentPost | null>(null)
const [lightboxIndex, setLightboxIndex] = useState<number>(0)
// Slideshow state
const [slideshowActive, setSlideshowActive] = useState(false)
const [slideshowFlatAttachments, setSlideshowFlatAttachments] = useState<PaidContentAttachment[]>([])
const [slideshowAttachmentPostMap, setSlideshowAttachmentPostMap] = useState<Map<number, PaidContentPost>>(new Map())
const [slideshowPost, setSlideshowPost] = useState<PaidContentPost | null>(null)
const [slideshowIndex, setSlideshowIndex] = useState(0)
// Backend-driven shuffle state (mirrors private gallery pattern)
const [shuffleSeed, setShuffleSeed] = useState<number>(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<Set<number>>(new Set())
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
// Copy to Private Gallery modal state
const [showCopyToGalleryModal, setShowCopyToGalleryModal] = useState(false)
const [copyPost, setCopyPost] = useState<PaidContentPost | null>(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<number>()
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<number, PaidContentPost>()
const postMap = new Map<number, PaidContentPost>()
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<number, PaidContentPost>()
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<number, PaidContentPost>()
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<string, string[]> = {
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=<id> param
const [initialPostId] = useState<number | null>(() => {
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 (
<div className="space-y-4">
{/* BundleLightbox for gallery view thumbnails — uses flat list across all posts */}
{lightboxPost && viewMode === 'gallery' && galleryFlatAttachments.length > 0 && (
<BundleLightbox
post={lightboxPost}
attachments={galleryFlatAttachments}
currentIndex={lightboxIndex}
initialSlideshow={slideshowActive}
onClose={() => {
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 && (
<BundleLightbox
post={lightboxPost}
attachments={lightboxAttachments}
currentIndex={lightboxIndex}
initialSlideshow={slideshowActive}
onClose={() => {
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 && (
<BundleLightbox
post={slideshowAttachmentPostMap.get(slideshowFlatAttachments[slideshowIndex]?.id) || slideshowPost || posts[0]}
attachments={slideshowFlatAttachments}
currentIndex={slideshowIndex}
onClose={() => {
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<number, PaidContentPost>()
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 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
<ScrollText className="w-8 h-8 text-violet-500" />
Feed
</h1>
<p className="text-slate-600 dark:text-slate-400 mt-1 hidden sm:block">
Browse posts from tracked creators
</p>
</div>
<div className="flex items-center gap-2">
{/* 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
})() && (
<button
onClick={() => {
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"
>
<Play className="w-4 h-4" />
<span className="text-sm">Slideshow</span>
</button>
)}
{/* Tag Mode button - only show in gallery view */}
{viewMode === 'gallery' && !tagMode && (
<button
onClick={() => 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 className="w-4 h-4" />
<span className="text-sm">Tag Mode</span>
</button>
)}
{/* View toggle - Social + Gallery */}
<div className="flex items-center bg-secondary rounded-lg p-1">
<button
onClick={() => { 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"
>
<Columns className="w-4 h-4" />
</button>
<button
onClick={() => 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"
>
<LayoutGrid className="w-4 h-4" />
</button>
</div>
{/* Mobile filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className="lg:hidden flex items-center gap-2 px-3 py-2 rounded-lg bg-secondary text-foreground"
>
<span className="text-sm">Filters</span>
{activeFilters.length > 0 && (
<span className="w-2 h-2 rounded-full bg-blue-500"></span>
)}
</button>
</div>
</div>
{/* Filters - Using FilterBar component */}
<div className={`card-glass-hover rounded-xl p-4 relative z-30 ${showFilters ? 'block' : 'hidden lg:block'}`}>
<FilterBar
searchValue={filters.search || ''}
onSearchChange={(value) => 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)',
},
}}
/>
</div>
{/* Unviewed posts banner */}
{unviewedCount > 0 && !filters.unviewed_only && (
<div className="flex items-center justify-between gap-3 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-lg shadow-sm border border-blue-200 dark:border-blue-800 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-600 text-white text-sm font-bold shrink-0">
{unviewedCount}
</div>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{unviewedCount === 1 ? '1 unviewed post' : `${unviewedCount} unviewed posts`}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => 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"
>
<Eye className="w-3.5 h-3.5" />
View unviewed
</button>
<button
onClick={() => markAllViewedMutation.mutate()}
disabled={markAllViewedMutation.isPending}
className="px-3 py-1.5 rounded-md text-blue-700 dark:text-blue-300 text-sm hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
>
{markAllViewedMutation.isPending ? 'Marking...' : 'Mark all viewed'}
</button>
</div>
</div>
)}
{/* Active unviewed filter banner */}
{filters.unviewed_only && (
<div className="flex items-center justify-between gap-3 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-lg shadow-sm border border-blue-200 dark:border-blue-800 px-4 py-3">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
Showing unviewed posts
</span>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => 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'}
</button>
<button
onClick={() => 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
</button>
</div>
</div>
)}
{/* Unread messages banner */}
{unreadMessagesCount > 0 && (
<div className="flex items-center justify-between gap-3 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 rounded-lg shadow-sm border border-violet-200 dark:border-violet-800 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold shrink-0">
{unreadMessagesCount}
</div>
<span className="text-sm font-medium text-violet-700 dark:text-violet-300">
{unreadMessagesCount === 1 ? '1 unread message' : `${unreadMessagesCount} unread messages`}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<RouterLink
to="/paid-content/messages"
className="px-3 py-1.5 rounded-lg bg-violet-600 text-white text-sm font-medium hover:bg-violet-700 transition-colors flex items-center gap-1.5"
>
<MessageSquare className="w-3.5 h-3.5" />
View messages
</RouterLink>
<button
onClick={() => markAllMessagesReadMutation.mutate()}
disabled={markAllMessagesReadMutation.isPending}
className="px-3 py-1.5 rounded-md text-violet-700 dark:text-violet-300 text-sm hover:bg-violet-100 dark:hover:bg-violet-900/30 transition-colors"
>
{markAllMessagesReadMutation.isPending ? 'Marking...' : 'Mark all read'}
</button>
</div>
</div>
)}
{/* Mobile: Full-screen post detail modal */}
{selectedPost && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background overflow-y-auto"
style={{ paddingTop: 'env(safe-area-inset-top)', overscrollBehavior: 'contain' }}
>
<PostDetail
post={selectedPost}
onClose={() => setSelectedPost(null)}
onPostUpdated={(updates) => {
if (updates) {
setSelectedPost(prev => prev ? { ...prev, ...updates } : null)
}
}}
/>
</div>
)}
{/* Gallery View */}
{viewMode === 'gallery' && (
<div className="bg-card rounded-xl border border-border overflow-hidden relative">
{/* Tag Mode Header Bar */}
{tagMode && (
<div className="sticky top-0 z-10 bg-primary/10 border-b border-primary/20 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-foreground">
{selectedPostIds.size} post{selectedPostIds.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={selectAllPosts}
className="text-sm text-primary hover:underline"
>
Select All ({posts.length})
</button>
{selectedPostIds.size > 0 && (
<button
onClick={() => setSelectedPostIds(new Set())}
className="text-sm text-muted-foreground hover:text-foreground"
>
Clear Selection
</button>
)}
</div>
<button
onClick={exitTagMode}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-secondary text-foreground hover:bg-secondary/80 transition-colors"
>
<X className="w-4 h-4" />
<span className="text-sm">Exit Tag Mode</span>
</button>
</div>
)}
<div
ref={viewMode === 'gallery' ? scrollContainerRef : undefined}
className="p-4 overflow-y-auto"
style={{ maxHeight: tagMode ? 'calc(100vh - 400px)' : 'calc(100vh - 280px)', minHeight: '400px' }}
>
{(isLoading || isPending) && (!posts || posts.length === 0) ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
</div>
) : isError && (!posts || posts.length === 0) ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<AlertTriangle className="w-12 h-12 mb-2 opacity-50 text-destructive" />
<p>Failed to load feed</p>
<button onClick={() => refetch()} className="mt-2 text-sm text-primary hover:underline">
Tap to retry
</button>
</div>
) : (!posts || posts.length === 0) ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<LayoutGrid className="w-12 h-12 mb-2 opacity-50" />
<p>No media found</p>
{hasActiveFilters && (
<button onClick={clearFilters} className="mt-2 text-sm text-primary hover:underline">
Clear filters
</button>
)}
</div>
) : (
<>
{/* Thumbnail grid - all attachments from all posts (pinned first, then regular - same as social view) */}
<div className="grid grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 gap-2">
{[...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 (
<button
key={`${post.id}-${att.id}`}
type="button"
className={`relative aspect-square bg-secondary rounded-lg overflow-hidden cursor-pointer transition-all group ${
tagMode && isPostSelected
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'hover:ring-2 hover:ring-primary'
}`}
onClick={() => {
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)
}
}}
>
<img
src={`/api/paid-content/files/thumbnail/${att.id}?size=large&${att.file_hash ? `v=${att.file_hash.slice(0, 8)}` : THUMB_CACHE_V}`}
alt=""
className={`w-full h-full object-cover transition-opacity ${
tagMode && isPostSelected ? 'opacity-80' : ''
}`}
loading="lazy"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
{/* Selection checkbox overlay in tag mode */}
{tagMode && (
<div className={`absolute top-2 left-2 w-6 h-6 rounded-md flex items-center justify-center transition-all ${
isPostSelected
? 'bg-primary text-primary-foreground'
: 'bg-black/50 text-white'
}`}>
{isPostSelected ? (
<Check className="w-4 h-4" />
) : (
<Square className="w-4 h-4" />
)}
</div>
)}
{/* Video indicator */}
{isVideo && !tagMode && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-10 h-10 rounded-full bg-black/50 flex items-center justify-center">
<Video className="w-5 h-5 text-white" />
</div>
</div>
)}
{/* Duration badge for videos */}
{isVideo && att.duration && (
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded">
{Math.floor(att.duration / 60)}:{Math.floor(att.duration % 60).toString().padStart(2, '0')}
</div>
)}
{/* Lock overlay for DRM preview frames */}
{_isPF && !tagMode && (
<div className="absolute inset-0 bg-black/20 flex items-center justify-center">
<Lock className="w-6 h-6 text-white/70" />
</div>
)}
{/* Creator/date info on hover - hide in tag mode */}
{!tagMode && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-white text-xs truncate font-medium">{post.display_name || post.username}</div>
<div className="text-white/70 text-xs">
@{post.username} · {post.published_at ? new Date(post.published_at).toLocaleDateString() : 'Unknown'}
</div>
</div>
)}
</button>
)
})
)}
</div>
{/* Infinite scroll trigger */}
<div className="p-4 flex items-center justify-center">
{isFetchingNextPage ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading more...</span>
</div>
) : hasNextPage ? (
<span className="text-sm text-muted-foreground">Scroll for more</span>
) : (posts && posts.length > 0) ? (
<span className="text-sm text-muted-foreground">End of gallery</span>
) : null}
</div>
</>
)}
</div>
{/* Tag Selection Bar - Bottom of gallery in tag mode */}
{tagMode && (
<div className="sticky bottom-0 z-10 bg-card border-t border-border p-4">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground flex items-center gap-2">
<Tag className="w-4 h-4" />
Select tags to apply:
</span>
{selectedTagIds.size > 0 && (
<span className="text-sm text-muted-foreground">
{selectedTagIds.size} tag{selectedTagIds.size !== 1 ? 's' : ''} selected
</span>
)}
</div>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{availableTags.map((tag) => {
const isSelected = selectedTagIds.has(tag.id)
return (
<button
key={tag.id}
type="button"
onClick={() => 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}
</button>
)
})}
</div>
<div className="flex items-center justify-end gap-3 pt-2 border-t border-border">
<button
onClick={exitTagMode}
className="px-4 py-2 rounded-lg bg-secondary text-foreground hover:bg-secondary/80 transition-colors"
>
Cancel
</button>
{isFeatureEnabled('/private-gallery') && (
<button
onClick={() => 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"
>
<ImagePlus className="w-4 h-4" />
Copy to Private ({selectedPostIds.size})
</button>
)}
<button
onClick={applyTags}
disabled={selectedPostIds.size === 0 || selectedTagIds.size === 0 || applyTagsMutation.isPending}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{applyTagsMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Applying...
</>
) : (
<>
<Check className="w-4 h-4" />
Apply Tags ({selectedPostIds.size} post{selectedPostIds.size !== 1 ? 's' : ''})
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
)}
{/* Social View - Desktop only (Fansly-style) */}
{viewMode === 'social' && (
<>
{/* BundleLightbox for social view */}
{socialLightboxPost && (
<BundleLightbox
post={socialLightboxPost}
attachments={socialLightboxPost.attachments?.filter(a => 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')
}
}}
/>
)}
<div className="hidden lg:block bg-card rounded-xl border border-border overflow-hidden">
<div className="flex h-[calc(100vh-280px)] min-h-[500px]">
{/* Post List - Left sidebar (narrower in social view) */}
<div
ref={viewMode === 'social' ? scrollContainerRef : undefined}
className="w-80 xl:w-96 border-r border-border overflow-y-auto flex-shrink-0"
>
{(isLoading || isPending) && (!posts || posts.length === 0) ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
</div>
) : isError && (!posts || posts.length === 0) ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<AlertTriangle className="w-12 h-12 mb-2 opacity-50 text-destructive" />
<p>Failed to load feed</p>
<button onClick={() => refetch()} className="mt-2 text-sm text-primary hover:underline">
Tap to retry
</button>
</div>
) : (!posts || posts.length === 0) ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<ScrollText className="w-12 h-12 mb-2 opacity-50" />
<p>No posts found</p>
{hasActiveFilters && (
<button onClick={clearFilters} className="mt-2 text-sm text-primary hover:underline">
Clear filters
</button>
)}
</div>
) : (
<>
{/* Pinned posts - collapsible */}
{pinnedPosts.length > 0 && (
<div className="border-b border-border">
<button
onClick={() => setPinnedCollapsed(!pinnedCollapsed)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-amber-500 hover:bg-accent/50"
>
<Pin className="w-3.5 h-3.5" />
<span>Pinned ({pinnedPosts.length})</span>
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${pinnedCollapsed ? '-rotate-90' : ''}`} />
</button>
{!pinnedCollapsed && pinnedPosts.map(post => (
<PostCard
key={post.id}
post={post}
isSelected={selectedPost?.id === post.id}
onClick={() => setSelectedPost(post)}
onOpenLightbox={(attachmentIndex) => {
setSocialLightboxPost(post)
setSocialLightboxIndex(attachmentIndex)
}}
onCopyToGallery={isFeatureEnabled('/private-gallery') ? setCopyPost : undefined}
/>
))}
</div>
)}
{/* Regular posts */}
{regularPosts.map((post) => (
<PostCard
key={post.id}
post={post}
isSelected={selectedPost?.id === post.id}
onClick={() => setSelectedPost(post)}
onOpenLightbox={(attachmentIndex) => {
setSocialLightboxPost(post)
setSocialLightboxIndex(attachmentIndex)
}}
onCopyToGallery={isFeatureEnabled('/private-gallery') ? setCopyPost : undefined}
/>
))}
{/* Infinite scroll trigger */}
<div className="p-4 flex items-center justify-center">
{isFetchingNextPage ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading more...</span>
</div>
) : hasNextPage ? (
<span className="text-sm text-muted-foreground">Scroll for more</span>
) : (posts && posts.length > 0) ? (
<span className="text-sm text-muted-foreground">No more posts</span>
) : null}
</div>
</>
)}
</div>
{/* Fansly-style Post Detail - Right panel */}
{selectedPost ? (
<div className="flex-1 overflow-hidden">
<PostDetailView
post={selectedPost}
highlighted={selectedPost.is_pinned === 1}
onClose={() => 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}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Columns className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Select a post to view details</p>
</div>
</div>
)}
</div>
</div>
{/* Mobile - Continuous scroll of full posts (like Instagram) */}
<div className="lg:hidden space-y-4">
{(isLoading || isPending) && (!posts || posts.length === 0) ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
</div>
) : isError && (!posts || posts.length === 0) ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<AlertTriangle className="w-12 h-12 mb-2 opacity-50 text-destructive" />
<p>Failed to load feed</p>
<button onClick={() => refetch()} className="mt-2 text-sm text-primary hover:underline">
Tap to retry
</button>
</div>
) : (!posts || posts.length === 0) ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<ScrollText className="w-12 h-12 mb-2 opacity-50" />
<p>No posts found</p>
</div>
) : (
<>
{/* Pinned posts - collapsible (mobile) */}
{pinnedPosts.length > 0 && (
<div className="bg-card rounded-xl border border-border overflow-hidden">
<button
onClick={() => setPinnedCollapsed(!pinnedCollapsed)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-amber-500 hover:bg-accent/50"
>
<Pin className="w-3.5 h-3.5" />
<span>Pinned ({pinnedPosts.length})</span>
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${pinnedCollapsed ? '-rotate-90' : ''}`} />
</button>
{!pinnedCollapsed && pinnedPosts.map(post => (
<div
key={post.id}
className="border-t border-border cursor-pointer"
onClick={() => setMobileSelectedPost(post)}
>
<PostDetailView
post={post}
highlighted={true}
onPostUpdated={() => {
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}
/>
</div>
))}
</div>
)}
{/* Regular posts (mobile) */}
{regularPosts.map((post) => (
<div
key={post.id}
className="bg-card rounded-xl border border-border overflow-hidden cursor-pointer"
onClick={() => setMobileSelectedPost(post)}
>
<PostDetailView
post={post}
onPostUpdated={() => {
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}
/>
</div>
))}
{/* Infinite scroll trigger - mobile only (always social view) */}
<div className="p-4 flex items-center justify-center">
{isFetchingNextPage ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading more...</span>
</div>
) : hasNextPage ? (
<span className="text-sm text-muted-foreground">Scroll for more</span>
) : (posts && posts.length > 0) ? (
<span className="text-sm text-muted-foreground">No more posts</span>
) : null}
</div>
</>
)}
</div>
{/* Mobile Full Post Modal (hidden when lightbox is open) */}
{mobileSelectedPost && !socialLightboxPost && (
<div className="fixed inset-0 z-50 bg-background overflow-y-auto" style={{ paddingTop: 'env(safe-area-inset-top, 0px)' }}>
{/* Header with back button */}
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 py-3 flex items-center gap-3">
<button
onClick={() => setMobileSelectedPost(null)}
className="p-2 -ml-2 hover:bg-secondary rounded-lg"
>
<X className="w-5 h-5" />
</button>
<span className="font-medium">Post</span>
</div>
{/* Full post view */}
<div className="p-4">
<PostDetailView
post={mobileSelectedPost}
highlighted={mobileSelectedPost.is_pinned === 1}
onPostUpdated={() => {
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}
/>
</div>
</div>
)}
</>
)}
{/* Desktop: Split View / Mobile: Full-width post list (Feed view) */}
{/* Only show when feed view is active */}
{viewMode === 'feed' && (
<div className="bg-card rounded-xl border border-border overflow-hidden">
<div className="flex lg:h-[calc(100vh-280px)] lg:min-h-[500px]">
{/* Post List - Full width on mobile, sidebar on desktop */}
<div
ref={viewMode === 'feed' ? scrollContainerRef : undefined}
className={`w-full lg:w-1/3 lg:border-r lg:border-border overflow-y-auto ${
selectedPost ? 'lg:block' : ''
}`}
>
{(isLoading || isPending) && (!posts || posts.length === 0) ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
</div>
) : isError && (!posts || posts.length === 0) ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<AlertTriangle className="w-12 h-12 mb-2 opacity-50 text-destructive" />
<p>Failed to load feed</p>
<button onClick={() => refetch()} className="mt-2 text-sm text-primary hover:underline">
Tap to retry
</button>
</div>
) : (!posts || posts.length === 0) ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<ScrollText className="w-12 h-12 mb-2 opacity-50" />
<p>No posts found</p>
{hasActiveFilters && (
<button onClick={clearFilters} className="mt-2 text-sm text-primary hover:underline">
Clear filters
</button>
)}
</div>
) : (
<>
{/* Pinned posts - collapsible */}
{pinnedPosts.length > 0 && (
<div className="border-b border-border">
<button
onClick={() => setPinnedCollapsed(!pinnedCollapsed)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-amber-500 hover:bg-accent/50"
>
<Pin className="w-3.5 h-3.5" />
<span>Pinned ({pinnedPosts.length})</span>
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${pinnedCollapsed ? '-rotate-90' : ''}`} />
</button>
{!pinnedCollapsed && pinnedPosts.map(post => (
<PostCard
key={post.id}
post={post}
isSelected={selectedPost?.id === post.id}
onClick={() => setSelectedPost(post)}
onOpenLightbox={(attachmentIndex) => {
setLightboxPost(post)
setLightboxIndex(attachmentIndex)
}}
onCopyToGallery={isFeatureEnabled('/private-gallery') ? setCopyPost : undefined}
/>
))}
</div>
)}
{/* Regular posts */}
{regularPosts.map((post) => (
<PostCard
key={post.id}
post={post}
isSelected={selectedPost?.id === post.id}
onClick={() => setSelectedPost(post)}
onOpenLightbox={(attachmentIndex) => {
setLightboxPost(post)
setLightboxIndex(attachmentIndex)
}}
onCopyToGallery={isFeatureEnabled('/private-gallery') ? setCopyPost : undefined}
/>
))}
{/* Infinite scroll trigger */}
<div className="p-4 flex items-center justify-center">
{isFetchingNextPage ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading more...</span>
</div>
) : hasNextPage ? (
<span className="text-sm text-muted-foreground">Scroll for more</span>
) : (posts && posts.length > 0) ? (
<span className="text-sm text-muted-foreground">No more posts</span>
) : null}
</div>
</>
)}
</div>
{/* Desktop Post Detail - hidden on mobile (uses modal above) */}
{selectedPost && (
<div className="hidden lg:block lg:w-2/3">
<PostDetail
post={selectedPost}
onClose={() => setSelectedPost(null)}
onPostUpdated={(updates) => {
if (updates) {
setSelectedPost(prev => prev ? { ...prev, ...updates } : null)
}
}}
/>
</div>
)}
</div>
</div>
)}
{/* Copy to Private Gallery Modal (bulk - tag mode) */}
<CopyToGalleryModal
open={showCopyToGalleryModal}
onClose={() => 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 && (
<CopyToGalleryModal
open={true}
onClose={() => 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)}
/>
)}
</div>
)
}