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

428 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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>
)
}