4110 lines
170 KiB
TypeScript
4110 lines
170 KiB
TypeScript
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>
|
||
)
|
||
}
|