Initial commit — OFApp client + server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
258
client/src/pages/Downloads.jsx
Normal file
258
client/src/pages/Downloads.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { getDownloadHistory, getActiveDownloads, getUser } from '../api'
|
||||
import Spinner from '../components/Spinner'
|
||||
|
||||
export default function Downloads() {
|
||||
const [history, setHistory] = useState([])
|
||||
const [active, setActive] = 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] = await Promise.all([
|
||||
getDownloadHistory(),
|
||||
getActiveDownloads(),
|
||||
])
|
||||
|
||||
if (histData.error) {
|
||||
setError(histData.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const histList = Array.isArray(histData) ? histData : histData.list || []
|
||||
setHistory(histList)
|
||||
setActive(Array.isArray(activeData) ? activeData : [])
|
||||
setLoading(false)
|
||||
resolveUsernames(histList)
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
pollRef.current = setInterval(async () => {
|
||||
const activeData = await getActiveDownloads()
|
||||
if (activeData.error) return
|
||||
|
||||
const list = Array.isArray(activeData) ? activeData : []
|
||||
setActive((prev) => {
|
||||
// If something just finished, refresh history
|
||||
if (prev.length > 0 && list.length < prev.length) {
|
||||
getDownloadHistory().then((h) => {
|
||||
if (!h.error) setHistory(Array.isArray(h) ? h : h.list || [])
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
}, 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-6">
|
||||
<h1 className="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>
|
||||
)}
|
||||
|
||||
{/* 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 */}
|
||||
<div className="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={`grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 items-center ${
|
||||
index < history.length - 1 ? 'border-b border-[#1a1a1a]' : ''
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user