Files
OFApp/client/src/pages/Downloads.jsx
Trey t 4903b84aef Add mobile-first responsive design with bottom tab navigation
Converts desktop sidebar to hidden on mobile, adds bottom tab bar with
5 primary items and a "More" overflow menu. All pages get responsive
spacing, smaller touch targets, and tighter grids on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:58:48 -06:00

321 lines
11 KiB
JavaScript

import { useState, useEffect, useRef } from 'react'
import { getDownloadHistory, getActiveDownloads, getUser, getScrapeJobs } from '../api'
import Spinner from '../components/Spinner'
export default function Downloads() {
const [history, setHistory] = useState([])
const [active, setActive] = useState([])
const [scrapeJobs, setScrapeJobs] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const pollRef = useRef(null)
const [usernames, setUsernames] = useState({})
useEffect(() => {
loadAll()
startPolling()
return () => {
if (pollRef.current) clearInterval(pollRef.current)
}
}, [])
const loadAll = async () => {
setLoading(true)
setError(null)
const [histData, activeData, scrapeData] = await Promise.all([
getDownloadHistory(),
getActiveDownloads(),
getScrapeJobs(),
])
if (histData.error) {
setError(histData.error)
setLoading(false)
return
}
const histList = Array.isArray(histData) ? histData : histData.list || []
setHistory(histList)
setActive(Array.isArray(activeData) ? activeData : [])
setScrapeJobs(Array.isArray(scrapeData) ? scrapeData.filter(j => j.running) : [])
setLoading(false)
resolveUsernames(histList)
}
const startPolling = () => {
pollRef.current = setInterval(async () => {
const [activeData, scrapeData] = await Promise.all([
getActiveDownloads(),
getScrapeJobs(),
])
if (!activeData.error) {
const list = Array.isArray(activeData) ? activeData : []
setActive((prev) => {
if (prev.length > 0 && list.length < prev.length) {
getDownloadHistory().then((h) => {
if (!h.error) setHistory(Array.isArray(h) ? h : h.list || [])
})
}
return list
})
}
if (!scrapeData.error) {
setScrapeJobs(Array.isArray(scrapeData) ? scrapeData.filter(j => j.running) : [])
}
}, 2000)
}
const resolveUsernames = async (items) => {
const ids = [...new Set(items.map((i) => i.userId || i.user_id).filter(Boolean))]
for (const id of ids) {
if (usernames[id]) continue
const data = await getUser(id)
if (data && !data.error && data.username) {
setUsernames((prev) => ({ ...prev, [id]: data.username }))
}
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '--'
const d = new Date(dateStr)
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
if (loading) return <Spinner />
if (error) {
return (
<div className="text-center py-16">
<p className="text-red-400 mb-4">{error}</p>
<button
onClick={loadAll}
className="text-[#0095f6] hover:underline text-sm"
>
Try again
</button>
</div>
)
}
return (
<div>
<div className="mb-4 md:mb-6">
<h1 className="text-xl md:text-2xl font-bold text-white mb-1">Downloads</h1>
<p className="text-gray-500 text-sm">
Manage and monitor media downloads
</p>
</div>
{/* Active Downloads */}
{active.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
Active Downloads
</h2>
<div className="space-y-3">
{active.map((dl) => {
const uid = dl.user_id
const progress =
dl.total > 0
? Math.round((dl.completed / dl.total) * 100)
: 0
return (
<div
key={uid}
className="bg-[#161616] border border-[#222] rounded-lg p-4"
>
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-sm font-medium text-white">
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
</p>
<p className="text-xs text-gray-500">
{dl.completed || 0} / {dl.total || '?'} files
{dl.errors > 0 && (
<span className="text-red-400 ml-2">
({dl.errors} error{dl.errors !== 1 ? 's' : ''})
</span>
)}
</p>
</div>
<span className="text-xs text-[#0095f6] font-medium">
{progress}%
</span>
</div>
{/* Progress Bar */}
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5">
<div
className="bg-[#0095f6] h-1.5 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Scrape Jobs */}
{scrapeJobs.length > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
Scrape Jobs
</h2>
<div className="space-y-3">
{scrapeJobs.map((job) => {
const progress = job.progress.total > 0
? Math.round((job.progress.completed / job.progress.total) * 100)
: 0
return (
<div key={job.id} className="bg-[#161616] border border-[#222] rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
job.type === 'forum' ? 'bg-purple-500/10 text-purple-400' : 'bg-orange-500/10 text-orange-400'
}`}>
{job.type}
</span>
<p className="text-sm font-medium text-white">{job.folderName}</p>
</div>
<span className="text-xs text-[#0095f6] font-medium">{progress}%</span>
</div>
<p className="text-xs text-gray-500 mb-2">
{job.progress.completed} / {job.progress.total} {job.type === 'forum' ? 'pages' : 'files'}
{job.progress.errors > 0 && (
<span className="text-red-400 ml-2">({job.progress.errors} errors)</span>
)}
</p>
<div className="w-full bg-[#1a1a1a] rounded-full h-1.5">
<div
className="bg-[#0095f6] h-1.5 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Download History */}
<div>
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">
History
</h2>
{history.length === 0 && active.length === 0 ? (
<div className="text-center py-12 bg-[#161616] border border-[#222] rounded-lg">
<svg
className="w-12 h-12 text-gray-600 mx-auto mb-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
<p className="text-gray-500 text-sm">No download history yet</p>
<p className="text-gray-600 text-xs mt-1">
Start downloading from the Users page
</p>
</div>
) : history.length === 0 ? null : (
<div className="bg-[#161616] border border-[#222] rounded-lg overflow-hidden">
{/* Table Header - hidden on mobile */}
<div className="hidden md:grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 border-b border-[#222] text-xs font-semibold text-gray-500 uppercase tracking-wider">
<span>User</span>
<span className="text-right">Files</span>
<span className="text-right">Status</span>
<span className="text-right">Date</span>
</div>
{/* Table Rows */}
{history.map((item, index) => {
const uid = item.userId || item.user_id
return (
<div
key={uid || index}
className={`px-4 py-3 ${
index < history.length - 1 ? 'border-b border-[#1a1a1a]' : ''
}`}
>
{/* Desktop row */}
<div className="hidden md:grid grid-cols-[1fr_auto_auto_auto] gap-4 items-center">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-[#333] flex-shrink-0" />
<span className="text-sm text-white truncate">
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
</span>
</div>
<span className="text-sm text-gray-400 text-right tabular-nums">
{item.fileCount || item.file_count || 0}
</span>
<span className="text-right">
<StatusBadge status="complete" />
</span>
<span className="text-xs text-gray-500 text-right whitespace-nowrap">
{formatDate(item.lastDownload || item.last_download || item.completedAt || item.created_at)}
</span>
</div>
{/* Mobile row */}
<div className="md:hidden flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 rounded-full bg-[#333] flex-shrink-0" />
<div className="min-w-0">
<span className="text-sm text-white truncate block">
{usernames[uid] ? `@${usernames[uid]}` : `User ${uid}`}
</span>
<span className="text-xs text-gray-500">
{item.fileCount || item.file_count || 0} files
</span>
</div>
</div>
<StatusBadge status="complete" />
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
function StatusBadge({ status }) {
const styles = {
complete: 'bg-green-500/10 text-green-400',
completed: 'bg-green-500/10 text-green-400',
running: 'bg-blue-500/10 text-blue-400',
error: 'bg-red-500/10 text-red-400',
}
return (
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${
styles[status] || 'bg-gray-500/10 text-gray-400'
}`}
>
{status}
</span>
)
}