427
web/frontend/src/pages/paid-content/Notifications.tsx
Normal file
427
web/frontend/src/pages/paid-content/Notifications.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useBreadcrumb } from '../../hooks/useBreadcrumb'
|
||||
import { breadcrumbConfig } from '../../config/breadcrumbConfig'
|
||||
import {
|
||||
Bell,
|
||||
BellRing,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Video,
|
||||
Download,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Info,
|
||||
Clock,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { api, getErrorMessage, PaidContentNotification } from '../../lib/api'
|
||||
import { formatRelativeTime, formatBytes, THUMB_CACHE_V } from '../../lib/utils'
|
||||
import { notificationManager } from '../../lib/notificationManager'
|
||||
import BundleLightbox from '../../components/paid-content/BundleLightbox'
|
||||
import { PaidContentPost, PaidContentAttachment } from '../../lib/api'
|
||||
|
||||
interface MediaFile {
|
||||
file_path: string
|
||||
filename: string
|
||||
source?: string
|
||||
content_type?: string
|
||||
file_size?: number
|
||||
width?: number
|
||||
height?: number
|
||||
file_type?: string
|
||||
platform?: string
|
||||
post_id?: number | null
|
||||
post_content?: string
|
||||
post_date?: string
|
||||
duration?: number
|
||||
attachment_id?: number
|
||||
downloaded_at?: string
|
||||
}
|
||||
|
||||
// Create a minimal post object from notification data for BundleLightbox
|
||||
function createMinimalPost(notification: PaidContentNotification, mediaFiles: MediaFile[]): PaidContentPost {
|
||||
// Use post_content from the first media file if available (backend includes this)
|
||||
const firstMedia = mediaFiles[0]
|
||||
const postContent = firstMedia?.post_content || null
|
||||
const postDate = firstMedia?.post_date || notification.created_at
|
||||
|
||||
return {
|
||||
id: notification.post_id || 0,
|
||||
creator_id: notification.creator_id || 0,
|
||||
post_id: String(notification.post_id || ''),
|
||||
title: null,
|
||||
content: postContent,
|
||||
published_at: postDate,
|
||||
added_at: notification.created_at,
|
||||
has_attachments: 1,
|
||||
attachment_count: mediaFiles.length,
|
||||
downloaded: 1,
|
||||
download_date: notification.created_at,
|
||||
is_favorited: 0,
|
||||
is_viewed: 0,
|
||||
is_pinned: 0,
|
||||
pinned_at: null,
|
||||
username: notification.username || firstMedia?.source || '',
|
||||
platform: notification.platform || firstMedia?.platform || '',
|
||||
service_id: '',
|
||||
display_name: notification.username || firstMedia?.source || null,
|
||||
profile_image_url: null,
|
||||
identity_id: null,
|
||||
attachments: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Convert notification media files to attachment format
|
||||
function mediaFilesToAttachments(mediaFiles: MediaFile[]): PaidContentAttachment[] {
|
||||
return mediaFiles.map((mf, index) => ({
|
||||
id: mf.attachment_id || index,
|
||||
post_id: mf.post_id || 0,
|
||||
attachment_index: index,
|
||||
name: mf.filename,
|
||||
file_type: mf.file_type || (isVideoFile(mf.filename) ? 'video' : 'image'),
|
||||
extension: mf.filename.split('.').pop() || null,
|
||||
server_path: null,
|
||||
download_url: null,
|
||||
file_size: mf.file_size || null,
|
||||
width: mf.width || null,
|
||||
height: mf.height || null,
|
||||
duration: mf.duration || null,
|
||||
status: 'completed',
|
||||
local_path: mf.file_path,
|
||||
local_filename: mf.filename,
|
||||
file_hash: null,
|
||||
error_message: null,
|
||||
download_attempts: 0,
|
||||
downloaded_at: mf.downloaded_at || mf.post_date || null,
|
||||
created_at: mf.post_date || null,
|
||||
}))
|
||||
}
|
||||
|
||||
function isVideoFile(filename: string): boolean {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
return ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v'].includes(ext || '')
|
||||
}
|
||||
|
||||
import LazyThumbnail from '../../components/LazyThumbnail'
|
||||
|
||||
function NotificationIcon({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case 'new_content':
|
||||
return (
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<Download className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
)
|
||||
case 'download_complete':
|
||||
return (
|
||||
<div className="p-2 rounded-lg bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
)
|
||||
case 'sync_complete':
|
||||
return (
|
||||
<div className="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30">
|
||||
<RefreshCw className="w-4 h-4 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<div className="p-2 rounded-lg bg-red-100 dark:bg-red-900/30">
|
||||
<XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-900/30">
|
||||
<Info className="w-4 h-4 text-slate-600 dark:text-slate-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function PaidContentNotifications() {
|
||||
useBreadcrumb(breadcrumbConfig['/paid-content/notifications'])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const [lightboxNotification, setLightboxNotification] = useState<PaidContentNotification | null>(null)
|
||||
const [lightboxMediaFiles, setLightboxMediaFiles] = useState<MediaFile[]>([])
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number>(0)
|
||||
|
||||
const { data: notifications, isLoading } = useQuery<PaidContentNotification[]>({
|
||||
queryKey: ['paid-content-notifications'],
|
||||
queryFn: () => api.paidContent.getNotifications({}),
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
const deleteNotificationMutation = useMutation({
|
||||
mutationFn: (id: number) => api.paidContent.deleteNotification(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['paid-content-notifications'] })
|
||||
notificationManager.success('Deleted', 'Notification removed')
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
notificationManager.error('Error', getErrorMessage(error))
|
||||
},
|
||||
})
|
||||
|
||||
const totalCount = notifications?.length || 0
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: totalCount,
|
||||
byType: notifications?.reduce((acc, n) => {
|
||||
acc[n.notification_type] = (acc[n.notification_type] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>) || {},
|
||||
}
|
||||
|
||||
const getThumbnailUrl = (media: MediaFile) => {
|
||||
// Use ID-based endpoint if available (matches Feed), otherwise fall back to file path
|
||||
if (media.attachment_id) {
|
||||
return `/api/paid-content/files/thumbnail/${media.attachment_id}?size=large&${THUMB_CACHE_V}`
|
||||
}
|
||||
return `/api/paid-content/thumbnail?file_path=${encodeURIComponent(media.file_path)}`
|
||||
}
|
||||
|
||||
const openLightbox = (notification: PaidContentNotification, mediaFiles: MediaFile[], index: number) => {
|
||||
setLightboxNotification(notification)
|
||||
setLightboxMediaFiles(mediaFiles)
|
||||
setLightboxIndex(index)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold flex items-center gap-2 sm:gap-3">
|
||||
<BellRing className="w-8 h-8 text-violet-500" />
|
||||
Notification History
|
||||
</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400 mt-1">
|
||||
{totalCount} notification{totalCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="card-glass-hover rounded-xl p-4 border stat-card-green shadow-green-glow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total</p>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">{stats.total}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-emerald-500/20">
|
||||
<Bell className="w-6 h-6 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-glass-hover rounded-xl p-4 border stat-card-purple shadow-purple-glow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Sync Complete</p>
|
||||
<p className="text-2xl font-bold text-violet-600 dark:text-violet-400">
|
||||
{stats.byType['sync_complete'] || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-violet-500/20">
|
||||
<RefreshCw className="w-6 h-6 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-glass-hover rounded-xl p-4 border stat-card-red shadow-red-glow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Errors</p>
|
||||
<p className="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{stats.byType['error'] || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-red-500/20">
|
||||
<AlertCircle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : !notifications || notifications.length === 0 ? (
|
||||
<div className="card-glass-hover rounded-xl p-12 text-center">
|
||||
<Bell className="w-12 h-12 text-muted-foreground mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-semibold text-foreground">No notifications</h3>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
No notifications yet
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => {
|
||||
// Enrich media files with post_id from notification
|
||||
const mediaFiles = (notification.metadata?.media_files || []).map(mf => ({
|
||||
...mf,
|
||||
post_id: notification.post_id,
|
||||
platform: (mf as any).platform || notification.platform,
|
||||
source: mf.source || notification.username,
|
||||
}))
|
||||
const hasMedia = mediaFiles.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="card-glass-hover rounded-xl p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<NotificationIcon type={notification.notification_type} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{notification.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 whitespace-pre-line">{notification.message}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Delete this notification?')) {
|
||||
deleteNotificationMutation.mutate(notification.id)
|
||||
}
|
||||
}}
|
||||
className="p-1.5 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Delete notification"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground hover:text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatRelativeTime(notification.created_at)}</span>
|
||||
</span>
|
||||
{notification.username && (
|
||||
<span className="px-2 py-0.5 bg-secondary rounded-full">
|
||||
@{notification.username}
|
||||
</span>
|
||||
)}
|
||||
{notification.download_count !== undefined && notification.download_count > 0 && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
{notification.download_count} posts
|
||||
</span>
|
||||
)}
|
||||
{notification.file_count !== undefined && notification.file_count > 0 && (
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
{notification.file_count} files
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Thumbnails Grid */}
|
||||
{hasMedia && (
|
||||
<div className="pt-3 border-t border-border">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{mediaFiles.slice(0, 10).map((media: MediaFile, idx: number) => {
|
||||
const isMediaVideo = isVideoFile(media.filename)
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`relative ${isMediaVideo ? 'aspect-video' : 'aspect-square'} cursor-pointer group rounded-lg overflow-hidden border-2 border-border hover:border-primary transition-colors`}
|
||||
onClick={() => openLightbox(notification, mediaFiles, idx)}
|
||||
>
|
||||
<LazyThumbnail
|
||||
src={getThumbnailUrl(media)}
|
||||
alt={media.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Video badge - top left with icon and duration */}
|
||||
{isMediaVideo && (
|
||||
<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" />
|
||||
{media.duration ? `${Math.floor(media.duration / 60)}:${(media.duration % 60).toString().padStart(2, '0')}` : 'Video'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay with file info */}
|
||||
<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">
|
||||
{media.filename}
|
||||
</div>
|
||||
<div className="text-white/70 text-xs flex items-center space-x-2">
|
||||
{media.file_size && <span>{formatBytes(media.file_size)}</span>}
|
||||
{media.width && media.height && <span>{media.width}×{media.height}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{mediaFiles.length > 10 && (
|
||||
<div
|
||||
className="relative aspect-square cursor-pointer rounded-lg overflow-hidden border-2 border-border bg-secondary flex items-center justify-center"
|
||||
onClick={() => openLightbox(notification, mediaFiles, 10)}
|
||||
>
|
||||
<span className="text-2xl font-bold text-muted-foreground">
|
||||
+{mediaFiles.length - 10}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notification Types Legend */}
|
||||
<div className="bg-card rounded-xl border border-border p-4">
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">Notification Types</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIcon type="new_content" />
|
||||
<span className="text-sm text-muted-foreground">New Content</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIcon type="download_complete" />
|
||||
<span className="text-sm text-muted-foreground">Download Complete</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIcon type="sync_complete" />
|
||||
<span className="text-sm text-muted-foreground">Sync Complete</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIcon type="error" />
|
||||
<span className="text-sm text-muted-foreground">Error</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BundleLightbox */}
|
||||
{lightboxNotification && lightboxMediaFiles.length > 0 && (
|
||||
<BundleLightbox
|
||||
post={createMinimalPost(lightboxNotification, lightboxMediaFiles)}
|
||||
attachments={mediaFilesToAttachments(lightboxMediaFiles)}
|
||||
currentIndex={lightboxIndex}
|
||||
autoPlayVideo
|
||||
onNavigate={setLightboxIndex}
|
||||
onClose={() => {
|
||||
setLightboxNotification(null)
|
||||
setLightboxMediaFiles([])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user