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>
321 lines
11 KiB
JavaScript
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>
|
|
)
|
|
}
|