Sync from /srv/compose/unified-media-manager
This commit is contained in:
76
frontend/src/App.tsx
Normal file
76
frontend/src/App.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Routes, Route, NavLink } from 'react-router-dom'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { QueryProvider } from './api/queryClient'
|
||||
import { ToastProvider } from './components/Toast'
|
||||
import Loading from './components/Loading'
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const Library = lazy(() => import('./pages/Library'))
|
||||
const Discover = lazy(() => import('./pages/Discover'))
|
||||
const Calendar = lazy(() => import('./pages/Calendar'))
|
||||
const MediaDetail = lazy(() => import('./pages/MediaDetail'))
|
||||
const Queue = lazy(() => import('./pages/Queue'))
|
||||
const Requests = lazy(() => import('./pages/Requests'))
|
||||
const Activity = lazy(() => import('./pages/Activity'))
|
||||
const Blocklist = lazy(() => import('./pages/Blocklist'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Search = lazy(() => import('./pages/Search'))
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/library', label: 'Library' },
|
||||
{ to: '/discover', label: 'Discover' },
|
||||
{ to: '/calendar', label: 'Calendar' },
|
||||
{ to: '/queue', label: 'Queue' },
|
||||
{ to: '/search', label: 'Search' },
|
||||
{ to: '/activity', label: 'Activity' },
|
||||
{ to: '/requests', label: 'Requests' },
|
||||
{ to: '/blocklist', label: 'Blocklist' },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<ToastProvider>
|
||||
<div className="min-h-screen bg-gray-950">
|
||||
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-indigo-600 focus:text-white focus:px-4 focus:py-2 focus:rounded focus:outline-none focus:ring-2 focus:ring-indigo-400">
|
||||
Skip to content
|
||||
</a>
|
||||
<nav className="bg-gray-900 border-b border-gray-800 px-6 py-3 flex items-center gap-6">
|
||||
<h1 className="text-xl font-bold text-indigo-400">UMM</h1>
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`text-sm font-medium ${isActive ? 'text-indigo-400' : 'text-gray-400 hover:text-gray-200'}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<main id="main-content" className="p-6">
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/discover" element={<Discover />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/library/:type/:id" element={<MediaDetail />} />
|
||||
<Route path="/queue" element={<Queue />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/activity" element={<Activity />} />
|
||||
<Route path="/requests" element={<Requests />} />
|
||||
<Route path="/blocklist" element={<Blocklist />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</QueryProvider>
|
||||
)
|
||||
}
|
||||
64
frontend/src/api/client.ts
Normal file
64
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
const API = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
function getAPIKey(): string | null {
|
||||
return localStorage.getItem('umm_api_key')
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const key = getAPIKey()
|
||||
if (key) return { 'X-API-Key': key }
|
||||
return {}
|
||||
}
|
||||
|
||||
function jsonHeaders(): Record<string, string> {
|
||||
return { 'Content-Type': 'application/json', ...authHeaders() }
|
||||
}
|
||||
|
||||
export function setAPIKey(key: string) {
|
||||
localStorage.setItem('umm_api_key', key)
|
||||
}
|
||||
|
||||
export function clearAPIKey() {
|
||||
localStorage.removeItem('umm_api_key')
|
||||
}
|
||||
|
||||
export function hasAPIKey(): boolean {
|
||||
return !!getAPIKey()
|
||||
}
|
||||
|
||||
export async function fetchAPI<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, { ...init, headers: { ...authHeaders(), ...(init?.headers || {}) } })
|
||||
if (res.status === 401 && path.startsWith('/api/requests')) {
|
||||
throw new Error('Authentication required. Please set your API key in Settings.')
|
||||
}
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function postAPI<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function putAPI<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method: 'PUT',
|
||||
headers: jsonHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteAPI<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, { method: 'DELETE', headers: authHeaders(), ...init })
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
22
frontend/src/api/queryClient.tsx
Normal file
22
frontend/src/api/queryClient.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { queryClient }
|
||||
85
frontend/src/components/ConfirmModal.tsx
Normal file
85
frontend/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
destructive?: boolean
|
||||
confirmLabel?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
export default function ConfirmModal({ open, title, message, onConfirm, onCancel, destructive, confirmLabel, children }: ConfirmModalProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
const cancelRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Tab' && dialogRef.current) {
|
||||
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE))
|
||||
if (!focusable.length) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKey)
|
||||
cancelRef.current?.focus()
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [open, onCancel])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onCancel} role="presentation">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-modal-title"
|
||||
aria-describedby="confirm-modal-desc"
|
||||
className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="confirm-modal-title" className="text-xl font-semibold mb-2 text-gray-100">{title}</h3>
|
||||
<p id="confirm-modal-desc" className="text-gray-400 text-sm mb-4">{message}</p>
|
||||
{children}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button ref={cancelRef} onClick={onCancel} className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`px-4 py-2 text-sm rounded font-semibold min-h-[36px] ${
|
||||
destructive ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-indigo-500 hover:bg-indigo-400 text-white'
|
||||
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400`}
|
||||
>
|
||||
{confirmLabel ?? 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
frontend/src/components/ErrorBanner.tsx
Normal file
12
frontend/src/components/ErrorBanner.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function ErrorBanner({ error, onRetry }: { error: string; onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="bg-red-900/30 border border-red-800 rounded-lg p-4 mb-4">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
{onRetry && (
|
||||
<button onClick={onRetry} className="text-red-300 text-xs underline mt-1">
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/Loading.tsx
Normal file
7
frontend/src/components/Loading.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-indigo-400 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/Pagination.tsx
Normal file
52
frontend/src/components/Pagination.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
export default function Pagination({ total, page, pageSize, onPageChange }: {
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
onPageChange: (p: number) => void
|
||||
}) {
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
if (totalPages <= 1) return null
|
||||
|
||||
const pages: number[] = []
|
||||
const maxVisible = 7
|
||||
let start = Math.max(1, page - Math.floor(maxVisible / 2))
|
||||
const end = Math.min(totalPages, start + maxVisible - 1)
|
||||
start = Math.max(1, end - maxVisible + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<span className="text-sm text-gray-500">{total} items</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
aria-label="Previous page"
|
||||
className="px-3 py-1 text-sm rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-40"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
{pages.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`px-3 py-1 text-sm rounded ${p === page ? 'bg-indigo-400 text-gray-950' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
aria-label="Next page"
|
||||
className="px-3 py-1 text-sm rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-40"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
218
frontend/src/components/ReleaseSearchResults.tsx
Normal file
218
frontend/src/components/ReleaseSearchResults.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState } from 'react'
|
||||
import { postAPI } from '../api/client'
|
||||
import { useToast } from './Toast'
|
||||
|
||||
interface SearchResult {
|
||||
title: string
|
||||
guid: string
|
||||
size: number
|
||||
pub_date: string
|
||||
indexer_name: string
|
||||
indexer_priority: number
|
||||
quality: {
|
||||
title: string
|
||||
resolution: string
|
||||
source: string
|
||||
video_codec: string
|
||||
release_group: string
|
||||
parse_warning: boolean
|
||||
}
|
||||
quality_tier: {
|
||||
name: string
|
||||
rank: number
|
||||
resolution: string
|
||||
} | null
|
||||
seeders: number
|
||||
peers: number
|
||||
category: string
|
||||
download_url: string
|
||||
source_indexers: string[]
|
||||
}
|
||||
|
||||
interface GrabPayload {
|
||||
download_url: string
|
||||
title: string
|
||||
media_type: string
|
||||
quality: SearchResult['quality']
|
||||
indexer_name: string
|
||||
media_id: number
|
||||
}
|
||||
|
||||
interface ReleaseSearchResultsProps {
|
||||
results: SearchResult[]
|
||||
mediaId: number
|
||||
mediaType: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
type SortCol = 'quality' | 'size' | 'seeders' | 'age'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
return `${(bytes / 1024).toFixed(0)} KB`
|
||||
}
|
||||
|
||||
function formatAge(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days}d`
|
||||
const months = Math.floor(days / 30)
|
||||
return `${months}mo`
|
||||
}
|
||||
|
||||
export function ReleaseSearchResults({ results, mediaId, mediaType, loading }: ReleaseSearchResultsProps) {
|
||||
const [sortCol, setSortCol] = useState<SortCol>('quality')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
const [grabbing, setGrabbing] = useState<Set<string>>(new Set())
|
||||
const { showToast } = useToast()
|
||||
|
||||
function handleSort(col: SortCol) {
|
||||
if (sortCol === col) {
|
||||
setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortCol(col)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...results].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortCol) {
|
||||
case 'quality':
|
||||
cmp = (a.quality_tier?.rank ?? 999) - (b.quality_tier?.rank ?? 999)
|
||||
break
|
||||
case 'size':
|
||||
cmp = a.size - b.size
|
||||
break
|
||||
case 'seeders':
|
||||
cmp = a.seeders - b.seeders
|
||||
break
|
||||
case 'age':
|
||||
cmp = new Date(a.pub_date).getTime() - new Date(b.pub_date).getTime()
|
||||
break
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
|
||||
async function handleGrab(result: SearchResult) {
|
||||
setGrabbing(prev => new Set(prev).add(result.guid))
|
||||
try {
|
||||
await postAPI<{ queue_id: number }>('/api/releases/grab', {
|
||||
download_url: result.download_url,
|
||||
title: result.title,
|
||||
media_type: mediaType,
|
||||
quality: result.quality,
|
||||
indexer_name: result.indexer_name,
|
||||
media_id: mediaId,
|
||||
} satisfies GrabPayload)
|
||||
showToast(`✓ Grabbed "${result.title}"`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
showToast(`✗ Failed to grab: ${message}`)
|
||||
} finally {
|
||||
setGrabbing(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(result.guid)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function SortHeader({ col, label }: { col: SortCol; label: string }) {
|
||||
const active = sortCol === col
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSort(col)}
|
||||
className={`text-left text-xs font-semibold uppercase tracking-wide transition-colors ${
|
||||
active ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{label} {active && (sortDir === 'asc' ? '▲' : '▼')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-10 bg-gray-800 rounded" />
|
||||
<div className="h-10 bg-gray-800 rounded" />
|
||||
<div className="h-10 bg-gray-800 rounded" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return <div className="text-gray-500 text-sm text-center py-12">No releases found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="px-4 py-3"><SortHeader col="quality" label="Quality" /></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Title</th>
|
||||
<th className="px-4 py-3"><SortHeader col="size" label="Size" /></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Indexer</th>
|
||||
<th className="px-4 py-3"><SortHeader col="seeders" label="Seeders" /></th>
|
||||
<th className="px-4 py-3"><SortHeader col="age" label="Age" /></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide w-20">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(result => {
|
||||
const isGrabbing = grabbing.has(result.guid)
|
||||
const noUrl = !result.download_url
|
||||
return (
|
||||
<tr key={result.guid} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{result.quality_tier ? (
|
||||
<span className="text-gray-200">{result.quality_tier.name}</span>
|
||||
) : result.quality.resolution || result.quality.source ? (
|
||||
<span className="text-gray-200">{result.quality.resolution} {result.quality.source}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">Unknown</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-200 max-w-lg truncate" title={result.title}>
|
||||
{result.title}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{formatFileSize(result.size)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{result.indexer_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
<span className={result.seeders > 0 ? 'text-green-400' : ''}>{result.seeders}</span>
|
||||
{' / '}
|
||||
<span>{result.peers}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{formatAge(result.pub_date)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
disabled={isGrabbing || noUrl}
|
||||
onClick={() => handleGrab(result)}
|
||||
className="px-3 py-1 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGrabbing ? 'Grabbing...' : noUrl ? 'N/A' : 'Grab'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReleaseSearchResults
|
||||
23
frontend/src/components/StatusBadge.tsx
Normal file
23
frontend/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-900/30 text-yellow-400',
|
||||
approved: 'bg-blue-900/30 text-blue-400',
|
||||
searching: 'bg-cyan-900/30 text-cyan-400',
|
||||
downloading: 'bg-orange-900/30 text-orange-400',
|
||||
fulfilled: 'bg-green-900/30 text-green-400',
|
||||
failed: 'bg-red-900/30 text-red-400',
|
||||
available: 'bg-green-900/30 text-green-400',
|
||||
unavailable: 'bg-red-900/30 text-red-400',
|
||||
imported: 'bg-green-900/30 text-green-400',
|
||||
completed: 'bg-green-900/30 text-green-400',
|
||||
rejected: 'bg-red-900/30 text-red-400',
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
const colorClass = statusColors[status] ?? 'bg-gray-800 text-gray-400'
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colorClass}`} aria-label={status}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
32
frontend/src/components/Toast.tsx
Normal file
32
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext, useState, useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({ showToast: () => {} })
|
||||
|
||||
export function useToast(): ToastContextValue {
|
||||
return useContext(ToastContext)
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
const showToast = useCallback((message: string) => {
|
||||
setToast(message)
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{toast && (
|
||||
<div role="status" aria-live="polite" className="fixed bottom-4 right-4 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 shadow-lg z-50">
|
||||
<p className="text-sm text-gray-200">{toast}</p>
|
||||
</div>
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
22
frontend/src/index.css
Normal file
22
frontend/src/index.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid #818cf8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
input, select, textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
15
frontend/src/main.tsx
Normal file
15
frontend/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/*" element={<App />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
160
frontend/src/pages/Activity.tsx
Normal file
160
frontend/src/pages/Activity.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchAPI } from '../api/client'
|
||||
import Pagination from '../components/Pagination'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
import Loading from '../components/Loading'
|
||||
|
||||
interface ActivityEvent {
|
||||
id: number
|
||||
event_type: string
|
||||
media_id: number | null
|
||||
media_type: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
data: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
data: ActivityEvent[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function eventTypeBadge(type: string): { label: string; className: string } {
|
||||
switch (type) {
|
||||
case 'grab':
|
||||
return { label: 'Grab', className: 'bg-blue-600' }
|
||||
case 'import':
|
||||
return { label: 'Import', className: 'bg-green-600' }
|
||||
case 'download_complete':
|
||||
return { label: 'Download', className: 'bg-green-600' }
|
||||
case 'download_failed':
|
||||
return { label: 'Failed', className: 'bg-red-600' }
|
||||
case 'quality_upgrade':
|
||||
return { label: 'Upgrade', className: 'bg-purple-600' }
|
||||
case 'safety_block':
|
||||
return { label: 'Blocked', className: 'bg-orange-600' }
|
||||
case 'error':
|
||||
return { label: 'Error', className: 'bg-red-600' }
|
||||
default:
|
||||
return { label: 'Info', className: 'bg-gray-600' }
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export default function Activity() {
|
||||
const [events, setEvents] = useState<ActivityEvent[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
|
||||
const fetchActivity = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
let url = `/api/activity?page=${page}&page_size=${PAGE_SIZE}`
|
||||
if (typeFilter) url += `&event_type=${typeFilter}`
|
||||
fetchAPI<PaginatedResponse>(url)
|
||||
.then(res => {
|
||||
setEvents(res.data ?? [])
|
||||
setTotal(res.total)
|
||||
})
|
||||
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [page, typeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchActivity()
|
||||
}, [fetchActivity])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-100">Activity</h2>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => { setTypeFilter(e.target.value); setPage(1) }}
|
||||
className="bg-gray-800 border border-gray-700 text-gray-200 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All Events</option>
|
||||
<option value="grab">Grabs</option>
|
||||
<option value="import">Imports</option>
|
||||
<option value="download_complete">Downloads</option>
|
||||
<option value="download_failed">Failures</option>
|
||||
<option value="quality_upgrade">Upgrades</option>
|
||||
<option value="safety_block">Safety Blocks</option>
|
||||
<option value="error">Errors</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} onRetry={fetchActivity} />}
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : events.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-20 text-sm">No activity events</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Type</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Media</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map(event => {
|
||||
const badge = eventTypeBadge(event.event_type)
|
||||
return (
|
||||
<tr key={event.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold text-white ${badge.className}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-100 truncate max-w-md">{event.title}</p>
|
||||
{event.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{event.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
{event.media_id != null ? `${event.media_type} #${event.media_id}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">{formatTimeAgo(event.created_at)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
243
frontend/src/pages/Blocklist.tsx
Normal file
243
frontend/src/pages/Blocklist.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchAPI, deleteAPI } from '../api/client'
|
||||
import { useToast } from '../components/Toast'
|
||||
import Pagination from '../components/Pagination'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
import Loading from '../components/Loading'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
|
||||
interface BlocklistItem {
|
||||
id: number
|
||||
release_title: string
|
||||
source_title: string | null
|
||||
quality: { resolution?: string } | null
|
||||
indexer: string | null
|
||||
protocol: string
|
||||
size: number | null
|
||||
message: string | null
|
||||
block_reason: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
data: BlocklistItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function Blocklist() {
|
||||
const { showToast } = useToast()
|
||||
const [items, setItems] = useState<BlocklistItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [clearAllOpen, setClearAllOpen] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const fetchBlocklist = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchAPI<PaginatedResponse>(`/api/blocklist?page=${page}&page_size=${PAGE_SIZE}`)
|
||||
.then(res => {
|
||||
setItems(res.data ?? [])
|
||||
setTotal(res.total)
|
||||
setSelected(new Set())
|
||||
})
|
||||
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
fetchBlocklist()
|
||||
}, [fetchBlocklist])
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selected.size === items.length) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(items.map(i => i.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSelected = async () => {
|
||||
setDeleting(true)
|
||||
let failed = 0
|
||||
for (const id of selected) {
|
||||
try {
|
||||
await deleteAPI(`/api/blocklist/${id}`)
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
showToast(failed ? `Deleted ${selected.size - failed} items, ${failed} failed` : `Deleted ${selected.size} items`)
|
||||
setDeleting(false)
|
||||
fetchBlocklist()
|
||||
}
|
||||
|
||||
const clearExpired = async () => {
|
||||
try {
|
||||
await deleteAPI('/api/blocklist/expired')
|
||||
showToast('Expired entries cleared')
|
||||
fetchBlocklist()
|
||||
} catch {
|
||||
showToast('Failed to clear expired entries')
|
||||
}
|
||||
}
|
||||
|
||||
const clearAll = async () => {
|
||||
try {
|
||||
await deleteAPI('/api/blocklist')
|
||||
showToast('All entries cleared')
|
||||
fetchBlocklist()
|
||||
} catch {
|
||||
showToast('Failed to clear blocklist')
|
||||
}
|
||||
setClearAllOpen(false)
|
||||
}
|
||||
|
||||
const deleteOne = async (id: number) => {
|
||||
try {
|
||||
await deleteAPI(`/api/blocklist/${id}`)
|
||||
setItems(items.filter(i => i.id !== id))
|
||||
setTotal(t => t - 1)
|
||||
showToast('Entry removed')
|
||||
} catch {
|
||||
showToast('Failed to remove entry')
|
||||
}
|
||||
}
|
||||
|
||||
const formatTimeAgo = (dateStr: string): string => {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-100">Blocklist</h2>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={clearExpired} className="bg-orange-600 hover:bg-orange-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
|
||||
Clear Expired
|
||||
</button>
|
||||
<button onClick={() => setClearAllOpen(true)} className="bg-red-600 hover:bg-red-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} onRetry={fetchBlocklist} />}
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-20 text-sm">No blocked releases</div>
|
||||
) : (
|
||||
<>
|
||||
{selected.size > 0 && (
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<span className="text-sm text-gray-400">{selected.size} selected</span>
|
||||
<button
|
||||
onClick={deleteSelected}
|
||||
disabled={deleting}
|
||||
className="bg-red-600 hover:bg-red-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px] disabled:opacity-50"
|
||||
>
|
||||
Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="px-4 py-3 w-10">
|
||||
<span className="sr-only">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.size === items.length && items.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded bg-gray-800 border-gray-600"
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Release</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Indexer</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Quality</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Reason</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Date</th>
|
||||
<th className="w-10 px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className={`border-b border-gray-800/50 hover:bg-gray-800/30 ${selected.has(item.id) ? 'bg-gray-800/20' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(item.id)}
|
||||
onChange={() => toggleSelect(item.id)}
|
||||
className="rounded bg-gray-800 border-gray-600"
|
||||
aria-label={`Select ${item.release_title}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-100 truncate max-w-md">{item.release_title}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{item.indexer ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{item.quality?.resolution ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400 capitalize">{item.block_reason}</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">{formatTimeAgo(item.created_at)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => deleteOne(item.id)} aria-label="Remove from blocklist" className="text-red-400 hover:text-red-300 text-xs min-h-[36px]">
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={clearAllOpen}
|
||||
title="Clear Blocklist"
|
||||
message="Remove all blocked releases? This cannot be undone."
|
||||
onConfirm={clearAll}
|
||||
onCancel={() => setClearAllOpen(false)}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
233
frontend/src/pages/Calendar.tsx
Normal file
233
frontend/src/pages/Calendar.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchAPI } from '../api/client'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
|
||||
interface CalendarEvent {
|
||||
id: number
|
||||
media_type: string
|
||||
title: string
|
||||
date: string
|
||||
year: number | null
|
||||
status: string
|
||||
poster_url: string
|
||||
}
|
||||
|
||||
interface CalendarResponse {
|
||||
data: CalendarEvent[]
|
||||
month: string
|
||||
}
|
||||
|
||||
const typeColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
movie: { bg: 'bg-indigo-600/20', border: 'border-l-indigo-500', text: 'text-indigo-300' },
|
||||
series: { bg: 'bg-emerald-600/20', border: 'border-l-emerald-500', text: 'text-emerald-300' },
|
||||
music: { bg: 'bg-amber-600/20', border: 'border-l-amber-500', text: 'text-amber-300' },
|
||||
audiobook: { bg: 'bg-rose-600/20', border: 'border-l-rose-500', text: 'text-rose-300' },
|
||||
podcast: { bg: 'bg-violet-600/20', border: 'border-l-violet-500', text: 'text-violet-300' },
|
||||
}
|
||||
|
||||
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
export default function Calendar() {
|
||||
const navigate = useNavigate()
|
||||
const now = useMemo(() => new Date(), [])
|
||||
|
||||
const [year, setYear] = useState(now.getFullYear())
|
||||
const [month, setMonth] = useState(now.getMonth())
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const monthParam = `${year}-${String(month + 1).padStart(2, '0')}`
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchAPI<CalendarResponse>(`/api/calendar?month=${monthParam}`)
|
||||
.then(res => {
|
||||
setEvents(res.data ?? [])
|
||||
})
|
||||
.catch(err => setError(err.message || 'Failed to load calendar events'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [monthParam])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const goToPrevMonth = () => {
|
||||
if (month === 0) {
|
||||
setMonth(11)
|
||||
setYear(y => y - 1)
|
||||
} else {
|
||||
setMonth(m => m - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToNextMonth = () => {
|
||||
if (month === 11) {
|
||||
setMonth(0)
|
||||
setYear(y => y + 1)
|
||||
} else {
|
||||
setMonth(m => m + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToToday = () => {
|
||||
setYear(now.getFullYear())
|
||||
setMonth(now.getMonth())
|
||||
}
|
||||
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
navigate(`/library/${event.media_type}/${event.id}`)
|
||||
}
|
||||
|
||||
// Calendar grid calculations
|
||||
const firstDay = new Date(year, month, 1).getDay()
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const today = new Date()
|
||||
const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month
|
||||
const todayDate = today.getDate()
|
||||
|
||||
// Group events by day
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<number, CalendarEvent[]>()
|
||||
for (const event of events) {
|
||||
const day = parseInt(event.date.split('-')[2] ?? '0', 10)
|
||||
if (day > 0) {
|
||||
const existing = map.get(day) ?? []
|
||||
existing.push(event)
|
||||
map.set(day, existing)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [events])
|
||||
|
||||
// Build the 42-cell grid (6 rows × 7 cols)
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
cells.push(null)
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
cells.push(d)
|
||||
}
|
||||
while (cells.length < 42) {
|
||||
cells.push(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-100">Calendar</h2>
|
||||
<p className="text-gray-500 text-sm">Upcoming release dates for monitored media</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="px-3 py-1.5 text-sm font-medium bg-gray-800 border border-gray-700 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={goToPrevMonth}
|
||||
aria-label="Previous month"
|
||||
className="p-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-gray-100 font-medium min-w-[140px] text-center">
|
||||
{monthNames[month]} {year}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNextMonth}
|
||||
aria-label="Next month"
|
||||
className="p-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} onRetry={fetchData} />}
|
||||
|
||||
{/* Day-of-week headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{dayLabels.map(d => (
|
||||
<div key={d} className="text-center text-xs font-medium text-gray-500 py-2">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: 42 }).map((_, i) => (
|
||||
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg min-h-[100px] p-2 animate-pulse">
|
||||
<div className="h-3 w-6 bg-gray-800 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((day, idx) => {
|
||||
if (day === null) {
|
||||
return <div key={`empty-${idx}`} className="min-h-[100px]" />
|
||||
}
|
||||
|
||||
const dayEvents = eventsByDay.get(day) ?? []
|
||||
const isToday = isCurrentMonth && day === todayDate
|
||||
const maxVisible = 3
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`day-${day}`}
|
||||
className="bg-gray-900 border border-gray-800 rounded-lg min-h-[100px] p-2"
|
||||
>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
isToday
|
||||
? 'text-indigo-400 ring-1 ring-indigo-500 rounded-full w-5 h-5 flex items-center justify-center'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{dayEvents.slice(0, maxVisible).map(event => {
|
||||
const colors = typeColors[event.media_type] ?? { bg: 'bg-gray-600/20', border: 'border-l-gray-500', text: 'text-gray-300' }
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => handleEventClick(event)}
|
||||
className={`w-full text-left text-xs px-1.5 py-0.5 rounded border-l-2 ${colors.bg} ${colors.border} ${colors.text} hover:brightness-125 transition-all line-clamp-1 cursor-pointer`}
|
||||
title={event.title}
|
||||
>
|
||||
{event.title}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{dayEvents.length > maxVisible && (
|
||||
<span className="text-xs text-gray-500 pl-1">
|
||||
+{dayEvents.length - maxVisible} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
frontend/src/pages/Dashboard.tsx
Normal file
90
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchAPI } from '../api/client'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
import Loading from '../components/Loading'
|
||||
|
||||
interface DashboardStats {
|
||||
total_media: number
|
||||
monitored: number
|
||||
unavailable: number
|
||||
available: number
|
||||
quality_upgrades: number
|
||||
queue_pending: number
|
||||
queue_downloading: number
|
||||
queue_failed: number
|
||||
blocklist_count: number
|
||||
blocklist_expired: number
|
||||
indexers_enabled: number
|
||||
recent_downloads: number
|
||||
media_by_type: Record<string, number>
|
||||
storage_by_type: Record<string, number>
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchDashboard = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchAPI<DashboardStats>('/api/dashboard')
|
||||
.then(setData)
|
||||
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard()
|
||||
}, [fetchDashboard])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6 text-gray-100">Dashboard</h2>
|
||||
<ErrorBanner error={error} onRetry={fetchDashboard} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{ label: 'Movies', value: data?.media_by_type?.movie ?? 0, color: 'bg-blue-600' },
|
||||
{ label: 'TV Series', value: data?.media_by_type?.series ?? 0, color: 'bg-green-600' },
|
||||
{ label: 'Music', value: data?.media_by_type?.music ?? 0, color: 'bg-purple-600' },
|
||||
{ label: 'Books', value: data?.media_by_type?.book ?? 0, color: 'bg-teal-600' },
|
||||
{ label: 'Active Downloads', value: data?.queue_downloading ?? 0, color: 'bg-orange-600' },
|
||||
{ label: 'Missing', value: data?.unavailable ?? 0, color: 'bg-red-600' },
|
||||
{ label: 'Upgrades Available', value: data?.quality_upgrades ?? 0, color: 'bg-cyan-600' },
|
||||
{ label: 'Total Storage', value: data ? formatStorage(data.storage_by_type) : '—', color: 'bg-gray-600' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6 text-gray-100">Dashboard</h2>
|
||||
{loading && !data ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{cards.map(card => (
|
||||
<div key={card.label} className={`${card.color} rounded-lg p-4`}>
|
||||
<p className="text-sm text-gray-200 font-normal">{card.label}</p>
|
||||
{loading ? (
|
||||
<div className="mt-2 h-8 w-16 bg-white/10 animate-pulse rounded" />
|
||||
) : (
|
||||
<p className="text-2xl font-semibold mt-1">{card.value}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatStorage(storageByType: Record<string, number>): string {
|
||||
const total = Object.values(storageByType).reduce((sum, v) => sum + v, 0)
|
||||
const tb = total / 1e12
|
||||
if (tb >= 1) return `${tb.toFixed(1)} TB`
|
||||
const gb = total / 1e9
|
||||
return `${gb.toFixed(1)} GB`
|
||||
}
|
||||
207
frontend/src/pages/Discover.tsx
Normal file
207
frontend/src/pages/Discover.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchAPI, postAPI } from '../api/client'
|
||||
import { useToast } from '../components/Toast'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
|
||||
interface DiscoverItem {
|
||||
tmdb_id: number
|
||||
title: string
|
||||
year: number | null
|
||||
media_type: string
|
||||
overview: string
|
||||
poster_url: string
|
||||
backdrop_url: string
|
||||
vote_average: number
|
||||
in_library: boolean
|
||||
}
|
||||
|
||||
interface DiscoverResponse {
|
||||
data: DiscoverItem[]
|
||||
page: number
|
||||
}
|
||||
|
||||
export default function Discover() {
|
||||
const { showToast } = useToast()
|
||||
const [items, setItems] = useState<DiscoverItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mediaType, setMediaType] = useState<string>('movie')
|
||||
const [tab, setTab] = useState<string>('trending')
|
||||
const [page, setPage] = useState(1)
|
||||
const [adding, setAdding] = useState<Set<number>>(new Set())
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchAPI<DiscoverResponse>(`/api/discover/${tab}?type=${mediaType}&page=${page}`)
|
||||
.then(res => {
|
||||
setItems(res.data ?? [])
|
||||
})
|
||||
.catch(err => setError(err.message || 'Failed to load discover content'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [tab, mediaType, page])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const handleMediaTypeChange = (value: string) => {
|
||||
setMediaType(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setTab(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleAddToLibrary = async (item: DiscoverItem) => {
|
||||
setAdding(prev => new Set(prev).add(item.tmdb_id))
|
||||
try {
|
||||
const result = await postAPI<{ id: number; existing?: boolean }>(
|
||||
'/api/discover/add',
|
||||
{ tmdb_id: item.tmdb_id, media_type: item.media_type }
|
||||
)
|
||||
setItems(prev =>
|
||||
prev.map(i => i.tmdb_id === item.tmdb_id ? { ...i, in_library: true } : i)
|
||||
)
|
||||
if (result.existing) {
|
||||
showToast(`"${item.title}" is already in your library`)
|
||||
} else {
|
||||
showToast(`Added "${item.title}" to library`)
|
||||
}
|
||||
} catch {
|
||||
showToast(`Failed to add "${item.title}" to library`)
|
||||
} finally {
|
||||
setAdding(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(item.tmdb_id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-2 text-gray-100">Discover</h2>
|
||||
<p className="text-gray-500 text-sm mb-6">Browse trending and popular content from TMDB</p>
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-700">
|
||||
<button
|
||||
onClick={() => handleTabChange('trending')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
tab === 'trending' ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Trending
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('popular')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
tab === 'popular' ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Popular
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={mediaType}
|
||||
onChange={e => handleMediaTypeChange(e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 text-sm"
|
||||
>
|
||||
<option value="movie">Movies</option>
|
||||
<option value="series">Series</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} onRetry={fetchData} />}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden animate-pulse">
|
||||
<div className="aspect-[2/3] bg-gray-800" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-20 text-sm">No results found</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.tmdb_id}
|
||||
className="group relative bg-gray-900 border border-gray-800 rounded-lg overflow-hidden hover:border-gray-700 transition-colors"
|
||||
>
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={item.poster_url}
|
||||
alt={item.title}
|
||||
className="w-full aspect-[2/3] object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-[2/3] bg-gray-800 flex items-center justify-center">
|
||||
<span className="text-3xl text-gray-400 font-bold">
|
||||
{item.title.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.in_library && (
|
||||
<div className="absolute top-2 right-2 bg-green-600 rounded-full w-6 h-6 flex items-center justify-center text-white text-xs font-bold z-10">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
|
||||
<p className="text-white text-sm font-medium leading-tight mb-1 line-clamp-2">{item.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-300 mb-2">
|
||||
{item.year && <span>{item.year}</span>}
|
||||
<span>⭐ {item.vote_average.toFixed(1)}</span>
|
||||
</div>
|
||||
{item.in_library ? (
|
||||
<button
|
||||
disabled
|
||||
className="bg-green-800 text-green-300 text-xs px-3 py-1.5 rounded font-medium cursor-not-allowed"
|
||||
>
|
||||
In Library
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleAddToLibrary(item)}
|
||||
disabled={adding.has(item.tmdb_id)}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-800 disabled:cursor-wait text-white text-xs px-3 py-1.5 rounded font-medium transition-colors"
|
||||
>
|
||||
{adding.has(item.tmdb_id) ? 'Adding...' : 'Add to Library'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mt-8">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-400">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= 10}
|
||||
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
frontend/src/pages/Library.tsx
Normal file
225
frontend/src/pages/Library.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { fetchAPI, putAPI } from '../api/client'
|
||||
import { useToast } from '../components/Toast'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import Pagination from '../components/Pagination'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
import Loading from '../components/Loading'
|
||||
|
||||
interface MediaItem {
|
||||
id: number
|
||||
media_type: string
|
||||
title: string
|
||||
year: number | null
|
||||
status: string
|
||||
monitored: boolean
|
||||
current_quality: { resolution?: string } | null
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
data: MediaItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
const MEDIA_TYPES = ['all', 'movie', 'series', 'music', 'book', 'audiobook', 'podcast', 'photo', 'other']
|
||||
const STATUS_FILTERS = ['all', 'available', 'unavailable', 'downloading', 'failed']
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function Library() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const [items, setItems] = useState<MediaItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const q = searchParams.get('q') ?? ''
|
||||
const typeFilter = searchParams.get('type') ?? 'all'
|
||||
const statusFilter = searchParams.get('status') ?? 'all'
|
||||
const page = Number(searchParams.get('page') ?? '1')
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const fetchMedia = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
if (typeFilter !== 'all') params.set('type', typeFilter)
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter)
|
||||
params.set('page', String(page))
|
||||
params.set('page_size', String(PAGE_SIZE))
|
||||
|
||||
fetchAPI<PaginatedResponse>(`/api/media?${params}`)
|
||||
.then(res => {
|
||||
setItems(res.data ?? [])
|
||||
setTotal(res.total)
|
||||
})
|
||||
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [q, typeFilter, statusFilter, page])
|
||||
|
||||
useEffect(() => {
|
||||
fetchMedia()
|
||||
}, [fetchMedia])
|
||||
|
||||
const updateSearch = (key: string, value: string) => {
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (value && value !== 'all') {
|
||||
next.set(key, value)
|
||||
} else {
|
||||
next.delete(key)
|
||||
}
|
||||
if (key !== 'page') next.set('page', '1')
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearchInput = (value: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => updateSearch('q', value), 300)
|
||||
}
|
||||
|
||||
const toggleMonitored = async (item: MediaItem) => {
|
||||
const prev = item.monitored
|
||||
setItems(items.map(i => i.id === item.id ? { ...i, monitored: !prev } : i))
|
||||
try {
|
||||
await putAPI(`/api/media/${item.media_type}/${item.id}`, { monitored: !prev })
|
||||
} catch {
|
||||
setItems(items.map(i => i.id === item.id ? { ...i, monitored: prev } : i))
|
||||
showToast('Failed to update monitoring status')
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold mb-6 text-gray-100">Library</h2>
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
<label htmlFor="lib-search" className="sr-only">Search media</label>
|
||||
<input
|
||||
id="lib-search"
|
||||
type="text"
|
||||
placeholder="Search across all media..."
|
||||
defaultValue={q}
|
||||
onChange={e => handleSearchInput(e.target.value)}
|
||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 placeholder-gray-500 text-sm"
|
||||
/>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => updateSearch('type', e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 text-sm"
|
||||
>
|
||||
{MEDIA_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t === 'all' ? 'All Types' : t.charAt(0).toUpperCase() + t.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => updateSearch('status', e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 text-sm"
|
||||
>
|
||||
{STATUS_FILTERS.map(s => (
|
||||
<option key={s} value={s}>{s === 'all' ? 'All Statuses' : s.charAt(0).toUpperCase() + s.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} onRetry={fetchMedia} />}
|
||||
|
||||
{loading && !items.length ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg p-4 animate-pulse">
|
||||
<div className="h-4 bg-gray-800 rounded w-1/3 mb-2" />
|
||||
<div className="h-3 bg-gray-800 rounded w-1/5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-20 text-sm">
|
||||
{q || typeFilter !== 'all' || statusFilter !== 'all'
|
||||
? 'No media found matching your search'
|
||||
: 'Your library is empty. Add media or request new content.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Type</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Status</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Quality</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">Monitor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr
|
||||
key={item.id}
|
||||
tabIndex={0}
|
||||
role="link"
|
||||
aria-label={`View ${item.title}`}
|
||||
className="border-b border-gray-800/50 hover:bg-gray-800/30 cursor-pointer"
|
||||
onClick={() => navigate(`/library/${item.media_type}/${item.id}`)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/library/${item.media_type}/${item.id}`) } }}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-gray-100 font-normal hover:text-indigo-400">{item.title}</span>
|
||||
{item.year && <span className="text-xs text-gray-500 ml-2">{item.year}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs text-gray-400 capitalize">{item.media_type}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={item.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-gray-400">
|
||||
{item.current_quality?.resolution ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); toggleMonitored(item) }}
|
||||
role="switch"
|
||||
aria-checked={item.monitored}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900 ${
|
||||
item.monitored ? 'bg-indigo-500' : 'bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
|
||||
item.monitored ? 'translate-x-4' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={p => setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(p)); return n })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
757
frontend/src/pages/MediaDetail.tsx
Normal file
757
frontend/src/pages/MediaDetail.tsx
Normal file
@@ -0,0 +1,757 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { fetchAPI, postAPI, deleteAPI } from '../api/client'
|
||||
import { useToast } from '../components/Toast'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
import ReleaseSearchResults from '../components/ReleaseSearchResults'
|
||||
|
||||
interface Media {
|
||||
id: number
|
||||
media_type: string
|
||||
title: string
|
||||
sort_title: string
|
||||
original_title: string | null
|
||||
overview: string | null
|
||||
year: number | null
|
||||
status: string
|
||||
monitored: boolean
|
||||
external_ids: Record<string, string>
|
||||
metadata: Record<string, unknown>
|
||||
images: Array<{ url: string; type: string; width: number; height: number }>
|
||||
quality_profile_id: number | null
|
||||
current_quality: { resolution?: string; source?: string } | null
|
||||
desired_quality: { resolution?: string; source?: string } | null
|
||||
quality_upgrade_needed: boolean
|
||||
added_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface MediaFile {
|
||||
id: number
|
||||
media_id: number
|
||||
path: string
|
||||
original_path: string | null
|
||||
file_name: string
|
||||
file_size: number
|
||||
quality: { resolution?: string; source?: string; codec?: string } | null
|
||||
codec: string | null
|
||||
resolution: string | null
|
||||
source: string | null
|
||||
transcode_status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface MediaRelation {
|
||||
id: number
|
||||
parent_id: number
|
||||
child_id: number
|
||||
relation: string
|
||||
position: number | null
|
||||
season: number | null
|
||||
}
|
||||
|
||||
interface MediaDetail {
|
||||
media: Media
|
||||
files: MediaFile[]
|
||||
relations: MediaRelation[]
|
||||
}
|
||||
|
||||
interface QualityProfileInfo {
|
||||
id: number
|
||||
name: string
|
||||
cutoff_quality: Record<string, unknown>
|
||||
allowed_qualities: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
interface SubtitleInfo {
|
||||
file_name: string
|
||||
language: string
|
||||
language_code: string
|
||||
hi: boolean
|
||||
forced: boolean
|
||||
source: string
|
||||
}
|
||||
|
||||
interface FileWithSubtitles extends MediaFile {
|
||||
subtitles: SubtitleInfo[]
|
||||
}
|
||||
|
||||
interface EpisodeInfo {
|
||||
media_id: number
|
||||
title: string
|
||||
season: number
|
||||
episode: number
|
||||
status: string
|
||||
monitored: boolean
|
||||
air_date: string | null
|
||||
has_file: boolean
|
||||
quality: { resolution?: string } | null
|
||||
}
|
||||
|
||||
interface MediaHistoryItem {
|
||||
id: number
|
||||
event_type: string
|
||||
title: string
|
||||
description: string | null
|
||||
data: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface FullMediaDetail {
|
||||
media: MediaDetail
|
||||
quality_profile: QualityProfileInfo | null
|
||||
files_with_subtitles: FileWithSubtitles[]
|
||||
episodes: EpisodeInfo[]
|
||||
history: MediaHistoryItem[]
|
||||
}
|
||||
|
||||
interface ReleaseSearchResult {
|
||||
title: string
|
||||
guid: string
|
||||
size: number
|
||||
pub_date: string
|
||||
indexer_name: string
|
||||
indexer_priority: number
|
||||
quality: {
|
||||
title: string
|
||||
resolution: string
|
||||
source: string
|
||||
video_codec: string
|
||||
release_group: string
|
||||
parse_warning: boolean
|
||||
}
|
||||
quality_tier: { name: string; rank: number; resolution: string } | null
|
||||
seeders: number
|
||||
peers: number
|
||||
category: string
|
||||
download_url: string
|
||||
source_indexers: string[]
|
||||
}
|
||||
|
||||
interface SubtitleSearchResult {
|
||||
id: string
|
||||
file_name: string
|
||||
language: string
|
||||
language_code: string
|
||||
hi: boolean
|
||||
forced: boolean
|
||||
download_count: number
|
||||
release_name: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
function eventTypeBadge(type: string): { label: string; className: string } {
|
||||
switch (type) {
|
||||
case 'grab':
|
||||
return { label: 'Grab', className: 'bg-blue-600' }
|
||||
case 'import':
|
||||
return { label: 'Import', className: 'bg-green-600' }
|
||||
case 'download_complete':
|
||||
return { label: 'Download', className: 'bg-green-600' }
|
||||
case 'download_failed':
|
||||
return { label: 'Failed', className: 'bg-red-600' }
|
||||
case 'quality_upgrade':
|
||||
return { label: 'Upgrade', className: 'bg-purple-600' }
|
||||
case 'safety_block':
|
||||
return { label: 'Blocked', className: 'bg-orange-600' }
|
||||
case 'error':
|
||||
return { label: 'Error', className: 'bg-red-600' }
|
||||
default:
|
||||
return { label: 'Info', className: 'bg-gray-600' }
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
return `${(bytes / 1024).toFixed(0)} KB`
|
||||
}
|
||||
|
||||
type TabKey = 'overview' | 'search' | 'files' | 'episodes' | 'history'
|
||||
|
||||
export default function MediaDetail() {
|
||||
const { type, id } = useParams<{ type: string; id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [detail, setDetail] = useState<FullMediaDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview')
|
||||
const [searchResults, setSearchResults] = useState<ReleaseSearchResult[]>([])
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const { showToast } = useToast()
|
||||
const [subtitleSearchFile, setSubtitleSearchFile] = useState<FileWithSubtitles | null>(null)
|
||||
const [subtitleResults, setSubtitleResults] = useState<SubtitleSearchResult[]>([])
|
||||
const [subtitleSearchLoading, setSubtitleSearchLoading] = useState(false)
|
||||
const [subtitleDownloading, setSubtitleDownloading] = useState<Set<string>>(new Set())
|
||||
const [extracting, setExtracting] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!type || !id) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
|
||||
.then(res => setDetail(res))
|
||||
.catch(err => setError(err.message || 'Failed to load media detail'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [type, id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!type || !id || activeTab !== 'search') return
|
||||
const media = detail?.media.media
|
||||
if (!media) return
|
||||
setSearchLoading(true)
|
||||
setSearchError(null)
|
||||
const query = encodeURIComponent(media.title)
|
||||
fetchAPI<{ data: ReleaseSearchResult[]; total: number }>(`/api/releases/search?query=${query}&media_type=${media.media_type}`)
|
||||
.then(res => setSearchResults(res.data ?? []))
|
||||
.catch(err => setSearchError(err.message || 'Search failed'))
|
||||
.finally(() => setSearchLoading(false))
|
||||
}, [activeTab, type, id, detail?.media.media])
|
||||
|
||||
const searchSubtitles = async (file: FileWithSubtitles) => {
|
||||
if (!type || !id) return
|
||||
setSubtitleSearchFile(file)
|
||||
setSubtitleSearchLoading(true)
|
||||
setSubtitleResults([])
|
||||
try {
|
||||
const res = await fetchAPI<{ data: SubtitleSearchResult[] }>(
|
||||
`/api/media/${type}/${id}/subtitles/search?languages=en`
|
||||
)
|
||||
setSubtitleResults(res.data ?? [])
|
||||
} catch {
|
||||
showToast('Failed to search subtitles')
|
||||
} finally {
|
||||
setSubtitleSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadSub = async (result: SubtitleSearchResult) => {
|
||||
if (!type || !id) return
|
||||
setSubtitleDownloading(prev => new Set(prev).add(result.id))
|
||||
try {
|
||||
await postAPI(`/api/media/${type}/${id}/subtitles/download`, {
|
||||
subtitle_id: result.id,
|
||||
language_code: result.language_code,
|
||||
hi: result.hi,
|
||||
forced: result.forced,
|
||||
})
|
||||
showToast(`✓ Downloaded ${result.language} subtitle`)
|
||||
// Refresh media detail to show new subtitle
|
||||
const refreshed = await fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
|
||||
setDetail(refreshed)
|
||||
setSubtitleResults([])
|
||||
setSubtitleSearchFile(null)
|
||||
} catch {
|
||||
showToast('Failed to download subtitle')
|
||||
} finally {
|
||||
setSubtitleDownloading(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(result.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const extractSubs = async () => {
|
||||
if (!type || !id) return
|
||||
setExtracting(true)
|
||||
try {
|
||||
await postAPI(`/api/media/${type}/${id}/subtitles/extract`, {})
|
||||
showToast('✓ Subtitles extracted')
|
||||
const refreshed = await fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
|
||||
setDetail(refreshed)
|
||||
} catch {
|
||||
showToast('Failed to extract subtitles')
|
||||
} finally {
|
||||
setExtracting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefreshMetadata = async () => {
|
||||
if (!type || !id) return
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await postAPI(`/api/media/${type}/${id}/refresh-metadata`, {})
|
||||
showToast('✓ Metadata refreshed')
|
||||
const refreshed = await fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
|
||||
setDetail(refreshed)
|
||||
} catch {
|
||||
showToast('Failed to refresh metadata')
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteMedia = async () => {
|
||||
if (!type || !id) return
|
||||
setShowDeleteModal(false)
|
||||
try {
|
||||
await deleteAPI(`/api/media/${type}/${id}`)
|
||||
showToast('✓ Media deleted')
|
||||
navigate('/library')
|
||||
} catch {
|
||||
showToast('Failed to delete media')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-800 rounded w-1/3" />
|
||||
<div className="h-64 bg-gray-800 rounded" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} onRetry={() => window.location.reload()} />
|
||||
}
|
||||
|
||||
if (!detail) return null
|
||||
|
||||
const media = detail.media.media
|
||||
const posterImage = detail.media.media.images?.find(i => i.type === 'poster')
|
||||
const isSeries = media.media_type === 'series'
|
||||
|
||||
const metadata = media.metadata as Record<string, unknown> | null
|
||||
const tmdbRating = (metadata?.tmdb_rating as number) ?? null
|
||||
const genres = (metadata?.genres as string[]) ?? []
|
||||
|
||||
const totalSize = detail.files_with_subtitles.reduce((sum, f) => sum + f.file_size, 0)
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'search', label: 'Search' },
|
||||
{ key: 'files', label: 'Files' },
|
||||
...(isSeries ? [{ key: 'episodes' as TabKey, label: 'Episodes' }] : []),
|
||||
{ key: 'history', label: 'History' },
|
||||
]
|
||||
|
||||
// Group episodes by season
|
||||
const seasons: Record<number, EpisodeInfo[]> = {}
|
||||
if (isSeries && detail.episodes) {
|
||||
for (const ep of detail.episodes) {
|
||||
if (!seasons[ep.season]) seasons[ep.season] = []
|
||||
seasons[ep.season].push(ep)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/library')}
|
||||
className="text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
← Back to Library
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-2xl font-semibold text-gray-100">{media.title}</h2>
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-gray-800 text-gray-400 capitalize">
|
||||
{media.media_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRefreshMetadata}
|
||||
disabled={refreshing}
|
||||
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-4 py-2 rounded text-sm font-semibold min-h-[36px]"
|
||||
>
|
||||
{refreshing ? 'Refreshing...' : '↻ Refresh Metadata'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="text-sm text-red-400 hover:text-red-300 px-2 min-h-[36px]"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{detail.files_with_subtitles.length} file{detail.files_with_subtitles.length !== 1 ? 's' : ''} · {formatFileSize(totalSize)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="flex gap-6 border-b border-gray-800 mb-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`pb-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'border-b-2 border-indigo-400 text-indigo-400'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Poster */}
|
||||
<div>
|
||||
{posterImage ? (
|
||||
<img
|
||||
src={posterImage.url}
|
||||
alt={media.title}
|
||||
className="w-full max-w-xs rounded-lg shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full max-w-xs aspect-[2/3] bg-gray-800 rounded-lg flex items-center justify-center">
|
||||
<span className="text-4xl text-gray-500 font-bold">
|
||||
{media.title.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold text-white">{media.title}</h1>
|
||||
{media.year && (
|
||||
<span className="px-2 py-0.5 rounded text-sm bg-gray-800 text-gray-300">{media.year}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{media.original_title && media.original_title !== media.title && (
|
||||
<p className="text-sm text-gray-500">{media.original_title}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={media.status} />
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${media.monitored ? 'bg-indigo-900/30 text-indigo-400' : 'bg-gray-800 text-gray-500'}`}>
|
||||
{media.monitored ? 'Monitored' : 'Not Monitored'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tmdbRating != null && (
|
||||
<p className="text-sm text-gray-300">
|
||||
⭐ {tmdbRating.toFixed(1)} / 10
|
||||
</p>
|
||||
)}
|
||||
|
||||
{genres.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{genres.map(g => (
|
||||
<span key={g} className="px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-400">{g}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-gray-400">
|
||||
<span className="font-medium text-gray-300">Quality Profile:</span>{' '}
|
||||
{detail.quality_profile?.name ?? <span className="text-gray-500">None configured</span>}
|
||||
</div>
|
||||
|
||||
{(media.current_quality || media.desired_quality) && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
{media.current_quality && (
|
||||
<span className="text-gray-400">
|
||||
Current: <span className="text-gray-200">{media.current_quality.resolution ?? 'Unknown'}</span>
|
||||
</span>
|
||||
)}
|
||||
{media.desired_quality && (
|
||||
<span className="text-gray-400">
|
||||
Desired: <span className="text-gray-200">{media.desired_quality.resolution ?? 'Unknown'}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{media.overview && (
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-300 leading-relaxed">{media.overview}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Tab */}
|
||||
{activeTab === 'search' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Searching indexers for "<span className="text-gray-200">{media.title}</span>"
|
||||
</p>
|
||||
{searchResults.length > 0 && (
|
||||
<p className="text-xs text-gray-500">{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}</p>
|
||||
)}
|
||||
</div>
|
||||
{searchError ? (
|
||||
<ErrorBanner error={searchError} onRetry={() => setActiveTab('search')} />
|
||||
) : (
|
||||
<ReleaseSearchResults
|
||||
results={searchResults}
|
||||
mediaId={Number(id)}
|
||||
mediaType={media.media_type}
|
||||
loading={searchLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Tab */}
|
||||
{activeTab === 'files' && (
|
||||
detail.files_with_subtitles.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-20 text-sm">No imported files yet</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-end mb-3">
|
||||
<button
|
||||
onClick={extractSubs}
|
||||
disabled={extracting}
|
||||
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-4 py-2 rounded text-sm font-semibold min-h-[36px]"
|
||||
>
|
||||
{extracting ? 'Extracting...' : 'Extract All Subtitles'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">File Name</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Quality</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Size</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Source</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Codec</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Subtitles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.files_with_subtitles.map(file => (
|
||||
<tr key={file.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 text-sm text-gray-200 max-w-md truncate">{file.file_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
{file.quality?.resolution ?? file.resolution ?? 'Unknown'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{formatFileSize(file.file_size)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400 capitalize">{file.source ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{file.codec ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{file.subtitles && file.subtitles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{file.subtitles.map((sub, idx) => (
|
||||
<span key={idx} className="bg-gray-700 rounded px-2 py-0.5 text-xs text-gray-300 flex items-center gap-1">
|
||||
{sub.language_code.toUpperCase()}
|
||||
{sub.hi && <span className="text-yellow-400">SDH</span>}
|
||||
{sub.forced && <span className="text-blue-400">F</span>}
|
||||
<span className="text-gray-500 text-[10px]">{sub.source === 'extracted' ? 'EX' : 'DL'}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-600 text-xs">None</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => searchSubtitles(file)}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 ml-1 whitespace-nowrap"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{subtitleSearchFile && (
|
||||
<div className="mt-4 bg-gray-900 border border-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-200">
|
||||
Subtitles for “{subtitleSearchFile.file_name}”
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => { setSubtitleSearchFile(null); setSubtitleResults([]) }}
|
||||
className="text-gray-500 hover:text-gray-300 text-sm"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
{subtitleSearchLoading ? (
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-8 bg-gray-800 rounded" />
|
||||
<div className="h-8 bg-gray-800 rounded" />
|
||||
</div>
|
||||
) : subtitleResults.length === 0 ? (
|
||||
<div className="text-gray-500 text-sm text-center py-8">No subtitles found</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase">Language</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase">Release</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase w-20">Downloads</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase w-24">Source</th>
|
||||
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase w-20">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subtitleResults.map(result => {
|
||||
const isDownloading = subtitleDownloading.has(result.id)
|
||||
return (
|
||||
<tr key={result.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
||||
<td className="px-3 py-2 text-sm text-gray-200">
|
||||
{result.language}
|
||||
{result.hi && <span className="text-yellow-400 ml-1 text-xs">SDH</span>}
|
||||
{result.forced && <span className="text-blue-400 ml-1 text-xs">Forced</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-gray-400 max-w-xs truncate" title={result.release_name}>
|
||||
{result.release_name || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-gray-400">{result.download_count}</td>
|
||||
<td className="px-3 py-2 text-sm text-gray-400">{result.provider}</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
onClick={() => downloadSub(result)}
|
||||
disabled={isDownloading}
|
||||
className="px-3 py-1 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDownloading ? '...' : 'Download'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Episodes Tab */}
|
||||
{activeTab === 'episodes' && isSeries && (
|
||||
Object.keys(seasons).length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-20 text-sm">No episode information available</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.keys(seasons)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map(seasonNum => (
|
||||
<div key={seasonNum}>
|
||||
<h3 className="text-lg font-semibold text-gray-200 mb-3">Season {seasonNum}</h3>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">#</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Status</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">Monitor</th>
|
||||
<th className="text-center px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">File</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Quality</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{seasons[seasonNum].map(ep => (
|
||||
<tr key={ep.media_id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{ep.episode}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-200">{ep.title}</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={ep.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{ep.monitored ? (
|
||||
<span className="text-green-400">✓</span>
|
||||
) : (
|
||||
<span className="text-gray-600">✗</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{ep.has_file ? (
|
||||
<span className="text-green-400">✓</span>
|
||||
) : (
|
||||
<span className="text-gray-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
{ep.quality?.resolution ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* History Tab */}
|
||||
{activeTab === 'history' && (
|
||||
detail.history.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-20 text-sm">No history recorded for this item</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{detail.history.map(event => {
|
||||
const badge = eventTypeBadge(event.event_type)
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex gap-4 py-3 border-l-2 border-gray-800 pl-4 ml-2 hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold text-white ${badge.className}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-200 truncate">{event.title}</p>
|
||||
{event.description && (
|
||||
<p className="text-xs text-gray-500 mt-0.5">{event.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 text-xs text-gray-500">
|
||||
{formatTimeAgo(event.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={showDeleteModal}
|
||||
title="Delete Media"
|
||||
message={`Are you sure you want to delete "${media.title}"? This will remove the media item and its associated files from the library. Files on disk will not be deleted.`}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={handleDeleteMedia}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
333
frontend/src/pages/Queue.tsx
Normal file
333
frontend/src/pages/Queue.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { fetchAPI, deleteAPI, postAPI } from '../api/client'
|
||||
import { useToast } from '../components/Toast'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
import Loading from '../components/Loading'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import Pagination from '../components/Pagination'
|
||||
|
||||
interface QueueItem {
|
||||
id: number
|
||||
media_id: number
|
||||
release_title: string
|
||||
indexer: string
|
||||
download_client: string
|
||||
quality: { resolution?: string } | null
|
||||
size: number | null
|
||||
status: string
|
||||
progress: number
|
||||
error_message: string | null
|
||||
protocol: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ImportReport {
|
||||
imported: number
|
||||
skipped: number
|
||||
errors: number
|
||||
results: ImportResult[]
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
media_id: number
|
||||
media_type: string
|
||||
source_path: string
|
||||
dest_path: string
|
||||
file_size: number
|
||||
quality: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface ImportHistoryItem {
|
||||
id: number
|
||||
media_id: number
|
||||
media_type: string
|
||||
action: string
|
||||
release_title: string
|
||||
quality: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const TABS = ['all', 'downloading', 'pending', 'failed', 'imported', 'history'] as const
|
||||
|
||||
export default function Queue() {
|
||||
const { showToast } = useToast()
|
||||
const [items, setItems] = useState<QueueItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<string>('all')
|
||||
const [cancelTarget, setCancelTarget] = useState<QueueItem | null>(null)
|
||||
const [clearAllConfirm, setClearAllConfirm] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importHistory, setImportHistory] = useState<ImportHistoryItem[]>([])
|
||||
const [historyPage, setHistoryPage] = useState(1)
|
||||
const [historyTotal, setHistoryTotal] = useState(0)
|
||||
const historyPageSize = 50
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const fetchQueue = useCallback(() => {
|
||||
setError(null)
|
||||
const params = new URLSearchParams()
|
||||
if (activeTab !== 'all') params.set('status', activeTab)
|
||||
|
||||
fetchAPI<{ data: QueueItem[] }>(`/api/queue?${params}`)
|
||||
.then(res => setItems(res.data ?? []))
|
||||
.catch(err => {
|
||||
setError(err.message || 'Something went wrong. Please try again.')
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchQueue()
|
||||
|
||||
intervalRef.current = setInterval(fetchQueue, 5000)
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [fetchQueue])
|
||||
|
||||
const fetchImportHistory = useCallback(() => {
|
||||
fetchAPI<{ data: ImportHistoryItem[]; total: number; page: number; page_size: number; total_pages: number }>(`/api/imports/history?page=${historyPage}&page_size=${historyPageSize}`)
|
||||
.then(res => {
|
||||
setImportHistory(res.data ?? [])
|
||||
setHistoryTotal(res.total)
|
||||
})
|
||||
.catch(() => {
|
||||
setImportHistory([])
|
||||
setHistoryTotal(0)
|
||||
})
|
||||
}, [historyPage])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'history') {
|
||||
fetchImportHistory()
|
||||
}
|
||||
}, [activeTab, fetchImportHistory])
|
||||
|
||||
const cancelItem = async (item: QueueItem) => {
|
||||
try {
|
||||
await deleteAPI(`/api/queue/${item.id}`)
|
||||
setItems(items.filter(i => i.id !== item.id))
|
||||
showToast('Download cancelled')
|
||||
} catch {
|
||||
showToast('Failed to cancel download')
|
||||
}
|
||||
setCancelTarget(null)
|
||||
}
|
||||
|
||||
const retryItem = async (item: QueueItem) => {
|
||||
try {
|
||||
await postAPI(`/api/queue/${item.id}/retry`, {})
|
||||
showToast('Retrying download')
|
||||
fetchQueue()
|
||||
} catch {
|
||||
showToast('Failed to retry download')
|
||||
}
|
||||
}
|
||||
|
||||
const retryAllFailed = async () => {
|
||||
try {
|
||||
await postAPI('/api/queue/retry-failed', {})
|
||||
showToast('Retrying all failed downloads')
|
||||
fetchQueue()
|
||||
} catch {
|
||||
showToast('Failed to retry downloads')
|
||||
}
|
||||
}
|
||||
|
||||
const clearCompleted = async () => {
|
||||
try {
|
||||
await postAPI('/api/queue/clear', {})
|
||||
showToast('Completed downloads cleared')
|
||||
fetchQueue()
|
||||
} catch {
|
||||
showToast('Failed to clear downloads')
|
||||
}
|
||||
}
|
||||
|
||||
const triggerImport = async () => {
|
||||
setImporting(true)
|
||||
try {
|
||||
const report = await postAPI<ImportReport>('/api/imports/trigger', {})
|
||||
showToast(`Import complete: ${report.imported} imported, ${report.skipped} skipped, ${report.errors} errors`)
|
||||
fetchQueue()
|
||||
if (activeTab === 'history') fetchImportHistory()
|
||||
} catch {
|
||||
showToast('Failed to trigger import')
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number | null): string => {
|
||||
if (!bytes) return '—'
|
||||
const gb = bytes / 1e9
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1e6).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const failedCount = items.filter(i => i.status === 'failed').length
|
||||
const completedCount = items.filter(i => i.status === 'imported').length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-100">Download Queue</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={triggerImport}
|
||||
disabled={importing}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 px-4 py-2 rounded text-sm font-semibold min-h-[36px]"
|
||||
>
|
||||
{importing ? 'Importing...' : 'Check for Completed Downloads'}
|
||||
</button>
|
||||
{completedCount > 0 && (
|
||||
<button onClick={clearCompleted} className="bg-orange-600 hover:bg-orange-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
|
||||
Clear Completed
|
||||
</button>
|
||||
)}
|
||||
{failedCount > 0 && (
|
||||
<button onClick={retryAllFailed} className="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
|
||||
Retry All Failed
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-4">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => { setActiveTab(tab); setLoading(true) }}
|
||||
className={`px-4 py-2 text-sm rounded capitalize ${activeTab === tab ? 'bg-indigo-400 text-gray-950' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} onRetry={fetchQueue} />}
|
||||
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-20 text-sm">No active downloads</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<p className="text-sm text-gray-100 truncate">{item.release_title}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<StatusBadge status={item.status} />
|
||||
<span className="text-xs text-gray-500">{item.download_client}</span>
|
||||
{item.quality?.resolution && (
|
||||
<span className="text-xs text-gray-400">{item.quality.resolution}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">{formatSize(item.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.status === 'failed' && (
|
||||
<button onClick={() => retryItem(item)} className="text-xs text-indigo-400 hover:text-indigo-300 min-h-[36px] px-2">
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{(item.status === 'downloading' || item.status === 'pending' || item.status === 'failed') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (item.status === 'downloading') {
|
||||
setCancelTarget(item)
|
||||
} else {
|
||||
cancelItem(item)
|
||||
}
|
||||
}}
|
||||
className="text-xs text-red-400 hover:text-red-300 min-h-[36px] px-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(item.status === 'downloading' || item.status === 'pending') && (
|
||||
<div className="w-full bg-gray-800 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-indigo-400 h-1.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, item.progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{item.status === 'downloading' && (
|
||||
<p className="text-xs text-gray-500 mt-1">{(item.progress * 100).toFixed(0)}%</p>
|
||||
)}
|
||||
{item.error_message && (
|
||||
<p className="text-xs text-red-400 mt-1">{item.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
{importHistory.length === 0 ? (
|
||||
<div className="text-gray-500 text-center py-20 text-sm">No import history yet</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Release Title</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Media Type</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Quality</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-40">Imported At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importHistory.map(item => (
|
||||
<tr key={item.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 text-sm text-gray-200 max-w-md truncate">{item.release_title || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400 capitalize">{item.media_type}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{item.quality || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{new Date(item.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={historyTotal} page={historyPage} pageSize={historyPageSize} onPageChange={setHistoryPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!cancelTarget}
|
||||
title="Cancel Download"
|
||||
message={`Cancel "${cancelTarget?.release_title ?? ''}"? This will remove it from the queue.`}
|
||||
onConfirm={() => cancelTarget && cancelItem(cancelTarget)}
|
||||
onCancel={() => setCancelTarget(null)}
|
||||
destructive
|
||||
confirmLabel="Cancel Download"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={clearAllConfirm}
|
||||
title="Clear All Completed"
|
||||
message="Remove all completed downloads from the queue?"
|
||||
onConfirm={() => { clearCompleted(); setClearAllConfirm(false) }}
|
||||
onCancel={() => setClearAllConfirm(false)}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
454
frontend/src/pages/Requests.tsx
Normal file
454
frontend/src/pages/Requests.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchAPI, postAPI, putAPI, deleteAPI } from '../api/client'
|
||||
import { useToast } from '../components/Toast'
|
||||
import Pagination from '../components/Pagination'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
import Loading from '../components/Loading'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
|
||||
interface RequestItem {
|
||||
id: number
|
||||
media_id: number
|
||||
title: string
|
||||
media_type: string
|
||||
year: number | null
|
||||
quality_profile_id: number | null
|
||||
quality_profile_name: string | null
|
||||
root_folder_id: number | null
|
||||
root_folder_path: string | null
|
||||
status: string
|
||||
requested_by: string
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
data: RequestItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
interface QualityProfile {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface RootFolder {
|
||||
id: number
|
||||
path: string
|
||||
media_type: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'fulfilled', label: 'Fulfilled' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
]
|
||||
|
||||
const MEDIA_TYPES = ['movie', 'series', 'music', 'book', 'audiobook', 'podcast', 'other']
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
const months = Math.floor(days / 30)
|
||||
return `${months}mo ago`
|
||||
}
|
||||
|
||||
export default function Requests() {
|
||||
const { showToast } = useToast()
|
||||
const [requests, setRequests] = useState<RequestItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// New request modal
|
||||
const [showNewModal, setShowNewModal] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newType, setNewType] = useState('movie')
|
||||
const [newYear, setNewYear] = useState('')
|
||||
const [newQualityId, setNewQualityId] = useState<number | ''>('')
|
||||
const [newRootFolderId, setNewRootFolderId] = useState<number | ''>('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
// Reject confirmation
|
||||
const [rejectTarget, setRejectTarget] = useState<RequestItem | null>(null)
|
||||
|
||||
// Dropdown data
|
||||
const [qualityProfiles, setQualityProfiles] = useState<QualityProfile[]>([])
|
||||
const [rootFolders, setRootFolders] = useState<RootFolder[]>([])
|
||||
|
||||
const fetchRequests = useCallback(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
fetchAPI<PaginatedResponse>(`/api/requests?${params}`)
|
||||
.then(res => {
|
||||
setRequests(res.data ?? [])
|
||||
setTotal(res.total)
|
||||
})
|
||||
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [page, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests()
|
||||
}, [fetchRequests])
|
||||
|
||||
// Fetch dropdown data on mount
|
||||
useEffect(() => {
|
||||
fetchAPI<{ data: QualityProfile[] }>('/api/quality-profiles?page=1&page_size=100')
|
||||
.then(res => setQualityProfiles(res.data ?? []))
|
||||
.catch(() => {})
|
||||
|
||||
fetchAPI<{ data: RootFolder[] }>('/api/root-folders?page=1&page_size=100')
|
||||
.then(res => setRootFolders(res.data ?? []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleFilterChange = (value: string) => {
|
||||
setStatusFilter(value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const openNewModal = () => {
|
||||
setNewTitle('')
|
||||
setNewType('movie')
|
||||
setNewYear('')
|
||||
setNewQualityId('')
|
||||
setNewRootFolderId('')
|
||||
setFormError(null)
|
||||
setShowNewModal(true)
|
||||
}
|
||||
|
||||
const submitNewRequest = async () => {
|
||||
if (!newTitle.trim()) {
|
||||
setFormError('Title is required')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
title: newTitle.trim(),
|
||||
media_type: newType,
|
||||
}
|
||||
if (newYear) body.year = parseInt(newYear, 10)
|
||||
if (newQualityId !== '') body.quality_profile_id = newQualityId
|
||||
if (newRootFolderId !== '') body.root_folder_id = newRootFolderId
|
||||
|
||||
await postAPI('/api/requests', body)
|
||||
showToast('Request submitted')
|
||||
setShowNewModal(false)
|
||||
fetchRequests()
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to submit request'
|
||||
setFormError(msg)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const approveRequest = async (req: RequestItem) => {
|
||||
try {
|
||||
await putAPI(`/api/requests/${req.id}/approve`, {})
|
||||
showToast('Request approved — searching for release')
|
||||
fetchRequests()
|
||||
} catch {
|
||||
showToast('Failed to approve request')
|
||||
}
|
||||
}
|
||||
|
||||
const rejectRequest = async () => {
|
||||
if (!rejectTarget) return
|
||||
try {
|
||||
await putAPI(`/api/requests/${rejectTarget.id}/reject`, {})
|
||||
showToast('Request rejected')
|
||||
fetchRequests()
|
||||
} catch {
|
||||
showToast('Failed to reject request')
|
||||
}
|
||||
setRejectTarget(null)
|
||||
}
|
||||
|
||||
const withdrawRequest = async (req: RequestItem) => {
|
||||
try {
|
||||
await deleteAPI(`/api/requests/${req.id}`)
|
||||
showToast('Request withdrawn')
|
||||
fetchRequests()
|
||||
} catch {
|
||||
showToast('Failed to withdraw request')
|
||||
}
|
||||
}
|
||||
|
||||
// Filter root folders by selected media type
|
||||
const filteredFolders = rootFolders.filter(f => f.media_type === newType)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-100">Requests</h2>
|
||||
<button
|
||||
onClick={openNewModal}
|
||||
className="bg-indigo-400 hover:bg-indigo-500 px-4 py-2 rounded text-sm font-semibold text-gray-950 min-h-[36px]"
|
||||
>
|
||||
+ New Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
{FILTER_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => handleFilterChange(tab.value)}
|
||||
className={`px-4 py-2 rounded text-sm font-medium min-h-[36px] ${
|
||||
statusFilter === tab.value
|
||||
? 'bg-indigo-400 text-gray-950'
|
||||
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <ErrorBanner error={error} onRetry={fetchRequests} />}
|
||||
|
||||
{/* Loading */}
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : requests.length === 0 ? (
|
||||
/* Empty */
|
||||
<div className="text-gray-500 text-center py-20 text-sm">
|
||||
{statusFilter
|
||||
? `No ${statusFilter} requests`
|
||||
: 'No requests yet. Click + New Request to get started.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Request cards */}
|
||||
<div className="space-y-3">
|
||||
{requests.map(req => (
|
||||
<div
|
||||
key={req.id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-lg p-4"
|
||||
>
|
||||
{/* Header row */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-100">
|
||||
{req.title}
|
||||
{req.year ? (
|
||||
<span className="text-gray-400 font-normal"> ({req.year})</span>
|
||||
) : null}
|
||||
</h3>
|
||||
<StatusBadge status={req.status} />
|
||||
</div>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="text-sm text-gray-400 mb-3">
|
||||
<span>Requested by: {req.requested_by}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{formatTimeAgo(req.created_at)}</span>
|
||||
{req.quality_profile_name && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Quality: {req.quality_profile_name}</span>
|
||||
</>
|
||||
)}
|
||||
{req.root_folder_path && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Root: {req.root_folder_path}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions row */}
|
||||
{req.status === 'pending' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => approveRequest(req)}
|
||||
className="bg-green-600 hover:bg-green-700 text-sm font-medium px-4 py-2 rounded min-h-[36px]"
|
||||
aria-label="Approve request"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejectTarget(req)}
|
||||
className="bg-red-600 hover:bg-red-700 text-sm font-medium px-4 py-2 rounded min-h-[36px]"
|
||||
aria-label="Reject request"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Withdraw link for own pending/approved requests */}
|
||||
{(req.status === 'pending' || req.status === 'approved') && (
|
||||
<button
|
||||
onClick={() => withdrawRequest(req)}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 mt-2"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* New Request Modal */}
|
||||
{showNewModal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowNewModal(false)}>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-semibold text-gray-100">New Request</h3>
|
||||
<button onClick={() => setShowNewModal(false)} className="text-gray-500 hover:text-gray-300 text-lg">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<div className="bg-red-900/30 border border-red-800 rounded-lg p-3 mb-4">
|
||||
<p className="text-red-400 text-sm">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={e => setNewTitle(e.target.value)}
|
||||
placeholder="Movie or show title"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Type</label>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={e => {
|
||||
setNewType(e.target.value)
|
||||
setNewRootFolderId('')
|
||||
}}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
|
||||
>
|
||||
{MEDIA_TYPES.map(t => (
|
||||
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Year */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Year</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newYear}
|
||||
onChange={e => setNewYear(e.target.value)}
|
||||
placeholder="2024"
|
||||
min="1900"
|
||||
max="2099"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quality Profile */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Quality Profile</label>
|
||||
<select
|
||||
value={newQualityId}
|
||||
onChange={e => setNewQualityId(e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{qualityProfiles.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Root Folder */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Root Folder</label>
|
||||
<select
|
||||
value={newRootFolderId}
|
||||
onChange={e => setNewRootFolderId(e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{filteredFolders.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.path}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal actions */}
|
||||
<div className="flex gap-3 justify-end mt-6">
|
||||
<button
|
||||
onClick={() => setShowNewModal(false)}
|
||||
className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 min-h-[36px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submitNewRequest}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 text-sm bg-indigo-500 hover:bg-indigo-400 rounded font-semibold text-white min-h-[36px] disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reject Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
open={!!rejectTarget}
|
||||
title="Reject Request"
|
||||
message={`Reject "${rejectTarget?.title}"? The requester will be notified.`}
|
||||
onConfirm={rejectRequest}
|
||||
onCancel={() => setRejectTarget(null)}
|
||||
destructive
|
||||
confirmLabel="Reject"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
427
frontend/src/pages/Search.tsx
Normal file
427
frontend/src/pages/Search.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { fetchAPI, postAPI } from '../api/client'
|
||||
import { useToast } from '../components/Toast'
|
||||
import ErrorBanner from '../components/ErrorBanner'
|
||||
|
||||
interface MediaItem {
|
||||
id: number
|
||||
media_type: string
|
||||
title: string
|
||||
year: number | null
|
||||
status: string
|
||||
monitored: boolean
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
title: string
|
||||
guid: string
|
||||
size: number
|
||||
pub_date: string
|
||||
indexer_name: string
|
||||
indexer_priority: number
|
||||
quality: {
|
||||
title: string
|
||||
resolution: string
|
||||
source: string
|
||||
video_codec: string
|
||||
release_group: string
|
||||
parse_warning: boolean
|
||||
}
|
||||
quality_tier: {
|
||||
name: string
|
||||
rank: number
|
||||
resolution: string
|
||||
} | null
|
||||
seeders: number
|
||||
peers: number
|
||||
category: string
|
||||
download_url: string
|
||||
source_indexers: string[]
|
||||
}
|
||||
|
||||
type SortCol = 'quality' | 'size' | 'seeders' | 'age'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
return `${(bytes / 1024).toFixed(0)} KB`
|
||||
}
|
||||
|
||||
function formatAge(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return 'now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days}d`
|
||||
const months = Math.floor(days / 30)
|
||||
return `${months}mo`
|
||||
}
|
||||
|
||||
function extractSearchHint(title: string): string {
|
||||
// Take the first few meaningful words from a release title as a media search hint
|
||||
const cleaned = title.replace(/[.\-]+/g, ' ').trim()
|
||||
const words = cleaned.split(/\s+/)
|
||||
// Take up to 3 words, but stop at common quality/year markers
|
||||
const stopWords = new Set(['720p', '1080p', '2160p', '4k', 'bluray', 'webrip', 'webdl', 'hdtv', 'dvdrip', 'x264', 'x265', 'hevc', 'aac', 'dts'])
|
||||
const hint: string[] = []
|
||||
for (const word of words.slice(0, 6)) {
|
||||
if (stopWords.has(word.toLowerCase())) break
|
||||
if (/^\d{4}$/.test(word)) break
|
||||
hint.push(word)
|
||||
if (hint.length >= 3) break
|
||||
}
|
||||
return hint.join(' ') || title.slice(0, 30)
|
||||
}
|
||||
|
||||
export default function Search() {
|
||||
// Search state
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
|
||||
// Sorting
|
||||
const [sortCol, setSortCol] = useState<SortCol>('quality')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
|
||||
// Media selector modal state
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [pendingResult, setPendingResult] = useState<SearchResult | null>(null)
|
||||
const [mediaQuery, setMediaQuery] = useState('')
|
||||
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
|
||||
const [mediaLoading, setMediaLoading] = useState(false)
|
||||
|
||||
// Grab tracking
|
||||
const [grabbing, setGrabbing] = useState<Set<string>>(new Set())
|
||||
|
||||
const { showToast } = useToast()
|
||||
const mediaSearchRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const modalInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function handleSort(col: SortCol) {
|
||||
if (sortCol === col) {
|
||||
setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortCol(col)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...results].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortCol) {
|
||||
case 'quality':
|
||||
cmp = (a.quality_tier?.rank ?? 999) - (b.quality_tier?.rank ?? 999)
|
||||
break
|
||||
case 'size':
|
||||
cmp = a.size - b.size
|
||||
break
|
||||
case 'seeders':
|
||||
cmp = a.seeders - b.seeders
|
||||
break
|
||||
case 'age':
|
||||
cmp = new Date(a.pub_date).getTime() - new Date(b.pub_date).getTime()
|
||||
break
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
|
||||
async function doSearch() {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
setLoading(true)
|
||||
setHasSearched(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetchAPI<{ data: SearchResult[]; total: number }>(
|
||||
'/api/releases/search?query=' + encodeURIComponent(trimmed)
|
||||
)
|
||||
setResults(res.data ?? [])
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Search failed'
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
doSearch()
|
||||
}
|
||||
|
||||
// Media selector: open modal
|
||||
function openMediaSelector(result: SearchResult) {
|
||||
setPendingResult(result)
|
||||
const hint = extractSearchHint(result.title)
|
||||
setMediaQuery(hint)
|
||||
setMediaItems([])
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
// Fetch media items when modal query changes (debounced)
|
||||
const fetchMedia = useCallback(async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setMediaItems([])
|
||||
return
|
||||
}
|
||||
setMediaLoading(true)
|
||||
try {
|
||||
const res = await fetchAPI<{ data: MediaItem[]; total: number }>(
|
||||
'/api/search?q=' + encodeURIComponent(q) + '&page_size=20'
|
||||
)
|
||||
setMediaItems(res.data ?? [])
|
||||
} catch {
|
||||
setMediaItems([])
|
||||
} finally {
|
||||
setMediaLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Debounce media search input
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return
|
||||
if (mediaSearchRef.current) clearTimeout(mediaSearchRef.current)
|
||||
mediaSearchRef.current = setTimeout(() => {
|
||||
fetchMedia(mediaQuery)
|
||||
}, 300)
|
||||
return () => {
|
||||
if (mediaSearchRef.current) clearTimeout(mediaSearchRef.current)
|
||||
}
|
||||
}, [mediaQuery, modalOpen, fetchMedia])
|
||||
|
||||
// Focus modal input when modal opens
|
||||
useEffect(() => {
|
||||
if (modalOpen) {
|
||||
// Small delay to let modal render
|
||||
setTimeout(() => modalInputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [modalOpen])
|
||||
|
||||
async function handleGrabWithMedia(item: MediaItem) {
|
||||
if (!pendingResult) return
|
||||
setGrabbing(prev => new Set(prev).add(pendingResult.guid))
|
||||
try {
|
||||
await postAPI<{ queue_id: number }>('/api/releases/grab', {
|
||||
download_url: pendingResult.download_url,
|
||||
title: pendingResult.title,
|
||||
media_type: item.media_type,
|
||||
quality: pendingResult.quality,
|
||||
indexer_name: pendingResult.indexer_name,
|
||||
media_id: item.id,
|
||||
})
|
||||
showToast(`✓ Grabbed "${pendingResult.title}"`)
|
||||
setModalOpen(false)
|
||||
setPendingResult(null)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
showToast(`✗ Failed to grab: ${message}`)
|
||||
} finally {
|
||||
if (pendingResult) {
|
||||
setGrabbing(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(pendingResult.guid)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalOpen(false)
|
||||
setPendingResult(null)
|
||||
setMediaItems([])
|
||||
setMediaQuery('')
|
||||
}
|
||||
|
||||
function SortHeader({ col, label }: { col: SortCol; label: string }) {
|
||||
const active = sortCol === col
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSort(col)}
|
||||
className={`text-left text-xs font-semibold uppercase tracking-wide transition-colors ${
|
||||
active ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{label} {active && (sortDir === 'asc' ? '▲' : '▼')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-100 mb-6">Search Indexers</h2>
|
||||
|
||||
{/* Search input */}
|
||||
<form onSubmit={handleSearchSubmit} className="mb-6">
|
||||
<div className="flex gap-3 max-w-2xl">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search all indexers..."
|
||||
className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-3 w-full outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-6 py-3 rounded-lg font-semibold transition-colors"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-2">Search across all enabled indexers for any release</p>
|
||||
</form>
|
||||
|
||||
{/* Results area */}
|
||||
{!hasSearched && (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<p className="text-lg">Enter a search query to find releases</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearched && loading && (
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-10 bg-gray-800 rounded" />
|
||||
<div className="h-10 bg-gray-800 rounded" />
|
||||
<div className="h-10 bg-gray-800 rounded" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSearched && error && !loading && (
|
||||
<ErrorBanner error={error} onRetry={doSearch} />
|
||||
)}
|
||||
|
||||
{hasSearched && !loading && !error && results.length === 0 && (
|
||||
<div className="text-gray-500 text-center py-12">No releases found</div>
|
||||
)}
|
||||
|
||||
{hasSearched && !loading && !error && results.length > 0 && (
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm mb-3">{results.length} release{results.length !== 1 ? 's' : ''} found</p>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<th className="px-4 py-3"><SortHeader col="quality" label="Quality" /></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Title</th>
|
||||
<th className="px-4 py-3"><SortHeader col="size" label="Size" /></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Indexer</th>
|
||||
<th className="px-4 py-3"><SortHeader col="seeders" label="Seeders" /></th>
|
||||
<th className="px-4 py-3"><SortHeader col="age" label="Age" /></th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide w-28">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(result => {
|
||||
const isGrabbing = grabbing.has(result.guid)
|
||||
const noUrl = !result.download_url
|
||||
return (
|
||||
<tr key={result.guid} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{result.quality_tier ? (
|
||||
<span className="text-gray-200">{result.quality_tier.name}</span>
|
||||
) : result.quality.resolution || result.quality.source ? (
|
||||
<span className="text-gray-200">{result.quality.resolution} {result.quality.source}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">Unknown</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-200 max-w-lg truncate" title={result.title}>
|
||||
{result.title}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{formatFileSize(result.size)}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{result.indexer_name}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">
|
||||
<span className={result.seeders > 0 ? 'text-green-400' : ''}>{result.seeders}</span>
|
||||
{' / '}
|
||||
<span>{result.peers}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400">{formatAge(result.pub_date)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
disabled={isGrabbing || noUrl}
|
||||
onClick={() => openMediaSelector(result)}
|
||||
className="px-3 py-1 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGrabbing ? 'Grabbing...' : noUrl ? 'N/A' : 'Select & Grab'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media selector modal */}
|
||||
{modalOpen && pendingResult && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={closeModal}>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-lg w-full mx-4" onClick={e => e.stopPropagation()}>
|
||||
<h3 className="text-lg font-semibold text-gray-100 mb-1">Select Media Item</h3>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Associate "<span className="text-gray-200">{pendingResult.title}</span>" with a media item in your library
|
||||
</p>
|
||||
|
||||
{/* Media search input */}
|
||||
<input
|
||||
ref={modalInputRef}
|
||||
type="text"
|
||||
value={mediaQuery}
|
||||
onChange={e => setMediaQuery(e.target.value)}
|
||||
placeholder="Search your library..."
|
||||
className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-2.5 w-full mb-3 text-sm outline-none transition-colors"
|
||||
/>
|
||||
|
||||
{/* Media items list */}
|
||||
<div className="max-h-64 overflow-y-auto space-y-1">
|
||||
{mediaLoading && mediaItems.length === 0 && (
|
||||
<div className="text-gray-500 text-sm text-center py-6">Searching library...</div>
|
||||
)}
|
||||
{!mediaLoading && mediaItems.length === 0 && mediaQuery.trim() && (
|
||||
<div className="text-gray-500 text-sm text-center py-6">No matching media items found</div>
|
||||
)}
|
||||
{mediaItems.map(item => {
|
||||
const isGrabbingItem = grabbing.has(pendingResult.guid)
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
disabled={isGrabbingItem}
|
||||
onClick={() => handleGrabWithMedia(item)}
|
||||
className="w-full text-left px-3 py-2.5 rounded hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="text-sm text-gray-200">{item.title}</span>
|
||||
<span className="text-xs text-gray-500 ml-2">
|
||||
{item.media_type}{item.year ? ` · ${item.year}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Modal actions */}
|
||||
<div className="flex justify-end gap-3 mt-4 pt-3 border-t border-gray-800">
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1317
frontend/src/pages/Settings.tsx
Normal file
1317
frontend/src/pages/Settings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user