import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { useSearchParams, Link as RouterLink } from 'react-router-dom' import { useBreadcrumb } from '../../hooks/useBreadcrumb' import { breadcrumbConfig } from '../../config/breadcrumbConfig' import { ScrollText, Download, Calendar, User, Image as ImageIcon, Video, File, ChevronLeft, X, Heart, Loader2, Edit, RefreshCw, Save, Link, AlertTriangle, LayoutGrid, Tag, Check, CheckSquare, Square, Music, Trash2, Plus, Columns, Pin, ChevronDown, ImagePlus, Play, Lock, ExternalLink, MessageSquare, Eye, EyeOff, Clock, } from 'lucide-react' import { FilterBar, FilterSection, ActiveFilter } from '../../components/FilterPopover' import { api, wsClient, PaidContentPost, PaidContentAttachment, PaidContentCreator, PaidContentCreatorGroup } from '../../lib/api' import { formatRelativeTime, formatPlatformName, decodeHtmlEntities, cleanCaption, THUMB_CACHE_V } from '../../lib/utils' import PostDetailView from '../../components/paid-content/PostDetailView' import BundleLightbox from '../../components/paid-content/BundleLightbox' import { CopyToGalleryModal } from '../../components/private-gallery/CopyToGalleryModal' import { useEnabledFeatures } from '../../hooks/useEnabledFeatures' import TagSearchSelector from '../../components/private-gallery/TagSearchSelector' // Alias for backwards compatibility in this file const getServiceDisplayName = formatPlatformName interface FeedFilters { service?: string platform?: string creator_id?: number creator_ids?: number[] creator_group_id?: number content_type?: string // 'image', 'video', 'audio' min_resolution?: string // '720p', '1080p', '1440p', '4k' tag_id?: number tagged_user?: string favorites_only: boolean unviewed_only: boolean downloaded_only: boolean has_missing: boolean missing_description: boolean hide_empty: boolean date_from?: string date_to?: string sort_by: string sort_order: 'asc' | 'desc' search?: string } // Format file size helper function formatFileSize(bytes: number | null): string { if (!bytes) return '' if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` } // Format duration helper function formatDuration(seconds: number | null): string { if (!seconds) return '' const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) if (mins > 0) return `${mins}:${secs.toString().padStart(2, '0')}` return `0:${secs.toString().padStart(2, '0')}` } // Generate display title for posts without titles function getPostDisplayTitle(post: PaidContentPost): string { // Use actual title if available if (post.title && post.title.trim()) { return post.title.trim() } // Build a descriptive title from available data const parts: string[] = [] // Add date if available if (post.published_at) { const date = new Date(post.published_at) parts.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })) } // Add attachment info if (post.attachment_count && post.attachment_count > 0) { const imageCount = post.attachments?.filter(a => a.file_type === 'image').length || 0 const videoCount = post.attachments?.filter(a => a.file_type === 'video').length || 0 if (videoCount > 0 && imageCount > 0) { parts.push(`${imageCount} images, ${videoCount} videos`) } else if (videoCount > 0) { parts.push(`${videoCount} ${videoCount === 1 ? 'video' : 'videos'}`) } else if (imageCount > 0) { parts.push(`${imageCount} ${imageCount === 1 ? 'image' : 'images'}`) } else { parts.push(`${post.attachment_count} ${post.attachment_count === 1 ? 'file' : 'files'}`) } } // If we have parts, join them if (parts.length > 0) { return parts.join(' — ') } // Last resort: use post_id return `Post ${post.post_id?.slice(-8) || post.id}` } function AttachmentThumbnail({ attachment, onClick, onImport }: { attachment: PaidContentAttachment onClick?: () => void onImport?: (attachment: PaidContentAttachment) => void }) { const [thumbnailError, setThumbnailError] = useState(false) // Preview frames are DRM video thumbnails (jpg) — treat as images, not videos const pvImageExts = ['jpg', 'jpeg', 'png', 'webp', 'gif'] const isPF = attachment.file_type === 'video' && pvImageExts.includes(attachment.extension?.toLowerCase() || '') const isImage = attachment.file_type === 'image' || isPF const isVideo = attachment.file_type === 'video' && !isPF const fileUrl = attachment.local_path ? attachment.file_token ? `/api/paid-content/files/serve?t=${encodeURIComponent(attachment.file_token)}` : `/api/paid-content/files/serve?path=${encodeURIComponent(attachment.local_path)}` : null const isMissing = attachment.status === 'failed' || attachment.status === 'pending' const handleClick = () => { if (onClick) { onClick() } else if (fileUrl && attachment.status === 'completed') { window.open(fileUrl, '_blank') } } // Build metadata string for tooltip const metadata: string[] = [] if (attachment.name) metadata.push(attachment.name) if (attachment.file_size) metadata.push(formatFileSize(attachment.file_size)) if (attachment.width && attachment.height) metadata.push(`${attachment.width}×${attachment.height}`) if (attachment.duration) metadata.push(formatDuration(attachment.duration)) const canShowThumbnail = (isImage || isVideo) && attachment.status === 'completed' && attachment.id && !thumbnailError // Use file_hash as cache buster so thumbnails refresh when a file is re-uploaded const thumbCacheBuster = attachment.file_hash ? `v=${attachment.file_hash.slice(0, 8)}` : THUMB_CACHE_V return (
{canShowThumbnail ? ( // Use cached thumbnail for both images and videos {attachment.name} setThumbnailError(true)} /> ) : (
{isImage && } {isVideo &&
)} {/* Metadata overlay on hover */}
{attachment.name || `File ${attachment.attachment_index + 1}`}
{attachment.file_size && {formatFileSize(attachment.file_size)}} {attachment.width && attachment.height && {attachment.width}×{attachment.height}} {isVideo && attachment.duration && {formatDuration(attachment.duration)}}
{/* Status indicators */} {attachment.status === 'completed' && (
)} {attachment.status === 'pending' && (
Pending
)} {attachment.status === 'failed' && (
Failed
)} {isVideo && attachment.status === 'completed' && (
)} {isPF && attachment.status === 'completed' && (
)} {/* Import button for failed/pending attachments */} {isMissing && onImport && ( )}
) } // Import Modal for importing files from URLs or uploading directly function ImportUrlModal({ attachment, onClose, onSuccess }: { attachment: PaidContentAttachment onClose: () => void onSuccess: () => void | Promise }) { const [mode, setMode] = useState<'url' | 'file'>('url') const [url, setUrl] = useState('') const [selectedFile, setSelectedFile] = useState(null) const [isLoading, setIsLoading] = useState(false) const [isDownloading, setIsDownloading] = useState(false) const [progress, setProgress] = useState(null) const [error, setError] = useState(null) const [isDragging, setIsDragging] = useState(false) const fileInputRef = useRef(null) // Real-time WebSocket progress updates for URL downloads useEffect(() => { if (!isDownloading) return const handleProgress = (data: any) => { if (data.attachment_id !== attachment.id) return if (data.status === 'completed') { setIsDownloading(false) setProgress(null) onSuccess() onClose() } else if (data.status === 'failed') { setIsDownloading(false) setIsLoading(false) setError(data.message || 'Download failed') } else if (data.status === 'downloading') { setProgress(data.message || `Downloading: ${data.progress}%`) } } const unsubscribe = wsClient.on('attachment_download_progress', handleProgress) const pollInterval = setInterval(async () => { try { const att = await api.paidContent.getAttachment(attachment.id) if (att.status === 'completed') { setIsDownloading(false) setProgress('Complete! Refreshing...') await onSuccess() onClose() } else if (att.status === 'failed') { setIsDownloading(false) setIsLoading(false) setError(att.error_message || 'Download failed') } else if (att.status === 'downloading' && att.error_message) { setProgress(att.error_message) } } catch (err) { // Ignore polling errors } }, 2000) return () => { unsubscribe() clearInterval(pollInterval) } }, [isDownloading, attachment.id, onSuccess, onClose]) const handleImportUrl = async () => { if (!url.trim()) { setError('Please enter a URL') return } setIsLoading(true) setError(null) setProgress(null) try { const result = await api.paidContent.importUrlToAttachment(attachment.id, url.trim()) as { status: string error?: string message?: string } if (result.status === 'success') { await onSuccess() onClose() } else if (result.status === 'downloading') { setIsDownloading(true) setProgress('Starting download...') } else { setError(result.error || 'Import failed') setIsLoading(false) } } catch (err: any) { setError(err.message || 'Failed to import file') setIsLoading(false) } } const handleUploadFile = async () => { if (!selectedFile) { setError('Please select a file') return } setIsLoading(true) setError(null) setProgress('Uploading: 0%') try { await api.paidContent.uploadFileToAttachment( attachment.id, selectedFile, (loaded, total) => { const pct = Math.round((loaded / total) * 100) const loadedMB = (loaded / (1024 * 1024)).toFixed(1) const totalMB = (total / (1024 * 1024)).toFixed(1) setProgress(`Uploading: ${pct}% (${loadedMB}MB / ${totalMB}MB)`) } ) setProgress('Complete! Refreshing...') await onSuccess() onClose() } catch (err: any) { setError(err.message || 'Failed to upload file') setIsLoading(false) setProgress(null) } } const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDragging(false) const file = e.dataTransfer.files[0] if (file) { setSelectedFile(file) setError(null) } } const handleDragOver = (e: React.DragEvent) => { e.preventDefault() setIsDragging(true) } const handleDragLeave = () => { setIsDragging(false) } const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` } return (
e.stopPropagation()} >

Import Attachment

{!isDownloading && !isLoading && ( )}

Import file for: {attachment.name || `Attachment ${attachment.attachment_index + 1}`}

{/* Mode tabs */}
{/* URL input */} {mode === 'url' && (

Supported: Bunkr, Pixeldrain, Gofile, Cyberdrop, or direct file URLs

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() } }} />
)} {/* File upload */} {mode === 'file' && (
!isLoading && fileInputRef.current?.click()} className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${ isDragging ? 'border-blue-500 bg-blue-500/10' : selectedFile ? 'border-green-500 bg-green-500/10' : 'border-border hover:border-blue-500 hover:bg-secondary' }`} > {selectedFile ? (

{selectedFile.name}

{formatFileSize(selectedFile.size)}

Click to change file

) : (

Drop file here or click to browse

)}
{ const file = e.target.files?.[0] if (file) { setSelectedFile(file) setError(null) } }} />
)} {/* Progress display */} {(isDownloading || (isLoading && mode === 'file')) && progress && (
{progress}
{progress.includes('%') && (
)}
)} {error && (
{error}
)}
) } function PostCard({ post, isSelected, onClick, onOpenLightbox, onCopyToGallery, }: { post: PaidContentPost isSelected: boolean onClick: () => void onOpenLightbox?: (attachmentIndex: number) => void onCopyToGallery?: (post: PaidContentPost) => void }) { const queryClient = useQueryClient() const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null) const favoriteMutation = useMutation({ mutationFn: () => api.paidContent.toggleFavorite(post.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) }, }) const viewedMutation = useMutation({ mutationFn: () => api.paidContent.toggleViewed(post.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) queryClient.invalidateQueries({ queryKey: ['paid-content-unviewed-count'] }) }, }) const { data: watchLaterIds = [] } = useQuery({ queryKey: ['paid-content-watch-later-ids'], queryFn: () => api.paidContent.getWatchLaterAttachmentIds(), staleTime: 30000, }) const watchLaterSet = new Set(watchLaterIds) const hasWatchLaterItems = post.attachments?.some(a => a.status === 'completed' && watchLaterSet.has(a.id)) const firstCompletedAttachment = post.attachments?.find(a => a.status === 'completed') const addWatchLaterMutation = useMutation({ mutationFn: (attachmentId: number) => api.paidContent.addToWatchLater(attachmentId), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['paid-content-watch-later-ids'] }), }) const removeWatchLaterMutation = useMutation({ mutationFn: (attachmentId: number) => api.paidContent.removeFromWatchLater(attachmentId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['paid-content-watch-later-ids'] }) queryClient.invalidateQueries({ queryKey: ['paid-content-watch-later'] }) }, }) const toggleWatchLater = () => { if (!firstCompletedAttachment) return if (hasWatchLaterItems) { // Remove all completed attachments from watch later post.attachments?.filter(a => a.status === 'completed' && watchLaterSet.has(a.id)) .forEach(a => removeWatchLaterMutation.mutate(a.id)) } else { // Add first completed attachment addWatchLaterMutation.mutate(firstCompletedAttachment.id) } } // Get completed attachments for display const completedAttachments = post.attachments?.filter(a => a.status === 'completed') || [] const visualAttachments = completedAttachments.filter(a => a.file_type !== 'audio') const unavailableAttachments = post.attachments?.filter(a => a.status === 'unavailable') || [] const imageExts = ['jpg', 'jpeg', 'png', 'webp', 'gif'] const previewFrameIds = new Set( completedAttachments .filter(a => a.file_type === 'video' && imageExts.includes(a.extension?.toLowerCase() || '')) .map(a => a.id) ) const hasMoreAttachments = (post.attachment_count || 0) > 4 // Handle touch to distinguish between tap and scroll const handleTouchStart = (e: React.TouchEvent) => { touchStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY, time: Date.now() } } const handleTouchEnd = (e: React.TouchEvent) => { if (!touchStartRef.current) return const touch = e.changedTouches[0] const dx = Math.abs(touch.clientX - touchStartRef.current.x) const dy = Math.abs(touch.clientY - touchStartRef.current.y) const duration = Date.now() - touchStartRef.current.time // Only trigger click if movement was less than 20px and touch lasted at least 50ms (deliberate tap) if (dx < 20 && dy < 20 && duration >= 50) { onClick() } touchStartRef.current = null } return (
{ // Only handle click on desktop (no touch events) if (!('ontouchstart' in window)) { onClick() } }} onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd} > {/* Header row */}
{post.profile_image_url ? ( ) : (
)}

{post.display_name || post.username}

@{post.username}

{post.is_pinned === 1 && ( )} {onCopyToGallery && post.attachments?.some(a => a.status === 'completed' && a.local_path) && ( )} {firstCompletedAttachment && ( )}
{/* Tags row */} {(post.tags?.length || previewFrameIds.size > 0 || post.tags?.some(t => t.slug === 'ppv' || t.name === 'PPV')) ? (
{/* PPV tags first */} {(post.tags || []).filter(t => t.slug === 'ppv' || t.name === 'PPV').map((tag) => ( {tag.name} ))} {/* DRM badge second */} {previewFrameIds.size > 0 && ( DRM )} {/* Remaining tags */} {(post.tags || []).filter(t => t.slug !== 'ppv' && t.name !== 'PPV').map((tag) => ( {tag.name} ))}
) : null} {/* Content - For YouTube/Twitch, use title instead of content */} {(() => { const isVideoService = ['youtube', 'twitch', 'pornhub'].includes(post.platform?.toLowerCase() || '') const displayText = isVideoService && post.title ? post.title : (post.content || post.title) return displayText ? (

{cleanCaption(decodeHtmlEntities(displayText))}

) : null })()} {/* PPV Locked Attachments - placeholder grid (mobile only) */} {unavailableAttachments.length > 0 && completedAttachments.length === 0 && (
{unavailableAttachments.slice(0, 4).map((att) => (
{att.file_type === 'video' ? 'Video' : att.file_type === 'image' ? 'Image' : 'Media'} locked
))}
)} {/* Attachment Grid - Inline thumbnails (mobile only, desktop uses detail panel) */} {visualAttachments.length > 0 && (
{visualAttachments.slice(0, 4).map((att, idx) => { const isImage = att.file_type === 'image' const isVideo = att.file_type === 'video' return ( ) })}
)} {/* Inline Audio Player - mobile only, prevents tap from opening post */} {completedAttachments.some(a => a.file_type === 'audio') && (
e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} > {completedAttachments.filter(a => a.file_type === 'audio').map((audio) => { const audioUrl = audio.local_path ? audio.file_token ? `/api/paid-content/files/serve?t=${encodeURIComponent(audio.file_token)}` : `/api/paid-content/files/serve?path=${encodeURIComponent(audio.local_path)}` : null const fileSizeMB = audio.file_size ? (audio.file_size / 1024 / 1024).toFixed(1) : null return (
{audio.name || 'Audio'}
{fileSizeMB &&
{fileSizeMB} MB
}
) })}
)} {/* Footer */}
{post.published_at ? formatRelativeTime(post.published_at) : 'Unknown date'} {previewFrameIds.size > 0 && post.post_id && (post.service_id === 'onlyfans_direct' || post.platform === 'onlyfans') && ( e.stopPropagation()} className="inline-flex items-center gap-0.5 text-primary hover:underline" title="View this post on OnlyFans" > OF )} {previewFrameIds.size > 0 && post.post_id && (post.service_id === 'fansly_direct' || post.platform === 'fansly') && ( e.stopPropagation()} className="inline-flex items-center gap-0.5 text-primary hover:underline" title="View this post on Fansly" > Fansly )}
{/* Content type tags */} {(() => { const imageCount = post.attachments?.filter(a => a.file_type === 'image').length || 0 const videoCount = post.attachments?.filter(a => a.file_type === 'video').length || 0 const audioCount = post.attachments?.filter(a => a.file_type === 'audio').length || 0 return ( <> {imageCount > 0 && ( {imageCount} )} {videoCount > 0 && ( )} {audioCount > 0 && ( {audioCount} )} ) })()} {post.downloaded && ( )}
) } function PostDetail({ post, onClose, onPostUpdated }: { post: PaidContentPost; onClose: () => void; onPostUpdated?: (updatedPost?: Partial) => void }) { const [lightboxIndex, setLightboxIndex] = useState(null) const [isEditing, setIsEditing] = useState(false) const [editTitle, setEditTitle] = useState(post.title || '') const [editContent, setEditContent] = useState(post.content || '') const [editDate, setEditDate] = useState(post.published_at?.split('T')[0] || '') const [editTags, setEditTags] = useState(post.tags?.map(t => t.id) || []) const [importAttachment, setImportAttachment] = useState(null) const [isUploading, setIsUploading] = useState(false) const [deletingAttachmentId, setDeletingAttachmentId] = useState(null) const [confirmDeleteId, setConfirmDeleteId] = useState(null) // Selection mode for batch delete const [selectionMode, setSelectionMode] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [isBatchDeleting, setIsBatchDeleting] = useState(false) const lastSelectedAttIdx = useRef(null) const fileInputRef = useRef(null) const queryClient = useQueryClient() // Fetch available tags const { data: availableTags = [] } = useQuery({ queryKey: ['paid-content-tags'], queryFn: () => api.paidContent.getTags(), }) // Update edit state when post changes useEffect(() => { setEditTitle(post.title || '') setEditContent(post.content || '') setEditDate(post.published_at?.split('T')[0] || '') setEditTags(post.tags?.map(t => t.id) || []) setIsEditing(false) setConfirmDeleteId(null) setDeletingAttachmentId(null) setSelectionMode(false) setSelectedIds(new Set()) }, [post.id]) // Mutation for updating post const updateMutation = useMutation({ mutationFn: (data: { title?: string; content?: string; published_at?: string }) => api.put(`/paid-content/feed/${post.id}`, data), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) setIsEditing(false) // Pass updated fields back to parent so selectedPost updates immediately onPostUpdated?.(variables) }, onError: (error) => { console.error('Failed to update post:', error) }, }) // Mutation for refreshing metadata const refreshMutation = useMutation({ mutationFn: () => api.post(`/paid-content/feed/${post.id}/refresh`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) onPostUpdated?.() }, }) // Mutation for updating tags const tagsMutation = useMutation({ mutationFn: (tagIds: number[]) => api.paidContent.setPostTags(post.id, tagIds), onSuccess: (_, tagIds) => { queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) setIsEditing(false) // Pass updated tags back to parent so selectedPost updates immediately const updatedTags = availableTags.filter(t => tagIds.includes(t.id)) onPostUpdated?.({ tags: updatedTags }) }, }) const handleSave = async () => { const updates: { title?: string; content?: string; published_at?: string } = {} // Normalize null/undefined to empty string for comparison const originalTitle = post.title || '' const originalContent = post.content || '' const originalDate = post.published_at?.split('T')[0] || '' if (editTitle !== originalTitle) updates.title = editTitle if (editContent !== originalContent) updates.content = editContent if (editDate !== originalDate) updates.published_at = editDate ? `${editDate}T12:00:00` : undefined // Check if tags changed const currentTagIds = post.tags?.map(t => t.id) || [] const tagsChanged = editTags.length !== currentTagIds.length || editTags.some(id => !currentTagIds.includes(id)) if (Object.keys(updates).length > 0) { updateMutation.mutate(updates) } if (tagsChanged) { tagsMutation.mutate(editTags) } if (Object.keys(updates).length === 0 && !tagsChanged) { setIsEditing(false) } } // Handle file upload const handleFileUpload = async (event: React.ChangeEvent) => { const files = event.target.files if (!files || files.length === 0) return setIsUploading(true) try { for (const file of Array.from(files)) { await api.paidContent.uploadAttachment(post.id, file) } queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) onPostUpdated?.() } catch (error: any) { console.error('Upload failed:', error) alert(`Upload failed: ${error.message || 'Unknown error'}`) } finally { setIsUploading(false) // Reset file input if (fileInputRef.current) { fileInputRef.current.value = '' } } } // Delete attachment handler const handleDeleteAttachment = async (attachmentId: number) => { // If this is the last attachment, offer to delete the entire post if (completedAttachments.length === 1) { setConfirmDeleteId(null) if (confirm('This is the last attachment. Delete the entire post?')) { try { await api.paidContent.deletePost(post.id) queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) onClose() } catch (error) { console.error('Failed to delete post:', error) alert('Failed to delete post') } } return } setDeletingAttachmentId(attachmentId) setConfirmDeleteId(null) try { await api.paidContent.deleteAttachment(post.id, attachmentId, true) queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) onPostUpdated?.() } catch (error) { console.error('Failed to delete attachment:', error) alert('Failed to delete attachment') } finally { setDeletingAttachmentId(null) } } // Batch delete handler const handleBatchDelete = async () => { if (selectedIds.size === 0) return // If deleting all attachments, offer to delete the post if (selectedIds.size === completedAttachments.length) { if (confirm(`Delete all ${completedAttachments.length} attachments and the entire post? This cannot be undone.`)) { try { setIsBatchDeleting(true) await api.paidContent.deletePost(post.id) queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) onClose() } catch (error) { console.error('Failed to delete post:', error) alert('Failed to delete post') } finally { setIsBatchDeleting(false) } } return } if (!confirm(`Delete ${selectedIds.size} selected attachment${selectedIds.size > 1 ? 's' : ''}?`)) return setIsBatchDeleting(true) try { for (const id of selectedIds) { await api.paidContent.deleteAttachment(post.id, id, true) } setSelectedIds(new Set()) setSelectionMode(false) queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) onPostUpdated?.() } catch (error) { console.error('Failed to batch delete:', error) alert('Some attachments failed to delete') } finally { setIsBatchDeleting(false) } } // Lightbox delete handler (deletes current item and adjusts index) const handleLightboxDelete = async (attachment: PaidContentAttachment) => { if (!confirm('Delete this attachment?')) return try { await api.paidContent.deleteAttachment(post.id, attachment.id, true) queryClient.invalidateQueries({ queryKey: ['paid-content-feed'] }) onPostUpdated?.() // If no attachments left, close lightbox const remaining = completedAttachments.filter(a => a.id !== attachment.id) if (remaining.length === 0) { setLightboxIndex(null) return } // Adjust index if needed if (lightboxIndex !== null && lightboxIndex >= remaining.length) { setLightboxIndex(remaining.length - 1) } } catch (error) { console.error('Failed to delete attachment:', error) alert('Failed to delete attachment') } } // Filter to only completed media attachments for lightbox navigation const completedAttachments = post.attachments?.filter( (a) => a.status === 'completed' && (a.file_type === 'image' || a.file_type === 'video') ) || [] const openLightbox = (attachment: PaidContentAttachment) => { if (attachment.status !== 'completed') return const index = completedAttachments.findIndex((a) => a.id === attachment.id) if (index !== -1) { setLightboxIndex(index) } } return (
{/* BundleLightbox */} {lightboxIndex !== null && completedAttachments.length > 0 && ( setLightboxIndex(null)} onNavigate={setLightboxIndex} onDelete={handleLightboxDelete} /> )} {/* Header */}
{/* Mobile back button */}
{post.profile_image_url ? ( ) : (
)}

{post.display_name || post.username}

{post.platform} / {post.published_at ? formatRelativeTime(post.published_at) : 'Unknown date'}

{/* Action buttons */}
{isEditing ? ( <> ) : ( <> {/* Desktop close button */} )}
{/* Content */}
{isEditing ? ( <>
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" />
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" />