446 lines
16 KiB
TypeScript
446 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useRouter } from 'next/navigation'
|
|
import { useEffect, useState } from 'react'
|
|
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
|
|
|
|
export default function HomePage() {
|
|
const router = useRouter()
|
|
const [query, setQuery] = useState('')
|
|
const [recent, setRecent] = useState<
|
|
{
|
|
id: number
|
|
title: string
|
|
year?: number
|
|
statusLabel?: string
|
|
artwork?: { poster_url?: string }
|
|
}[]
|
|
>([])
|
|
const [recentError, setRecentError] = useState<string | null>(null)
|
|
const [recentLoading, setRecentLoading] = useState(false)
|
|
const [searchResults, setSearchResults] = useState<
|
|
{ title: string; year?: number; type?: string; requestId?: number; statusLabel?: string }[]
|
|
>([])
|
|
const [searchError, setSearchError] = useState<string | null>(null)
|
|
const [role, setRole] = useState<string | null>(null)
|
|
const [recentDays, setRecentDays] = useState(90)
|
|
const [authReady, setAuthReady] = useState(false)
|
|
const [servicesStatus, setServicesStatus] = useState<
|
|
{ overall: string; services: { name: string; status: string; message?: string }[] } | null
|
|
>(null)
|
|
const [servicesLoading, setServicesLoading] = useState(false)
|
|
const [servicesError, setServicesError] = useState<string | null>(null)
|
|
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
|
|
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
|
|
|
|
const submit = (event: React.FormEvent) => {
|
|
event.preventDefault()
|
|
const trimmed = query.trim()
|
|
if (!trimmed) return
|
|
if (/^\d+$/.test(trimmed)) {
|
|
router.push(`/requests/${encodeURIComponent(trimmed)}`)
|
|
return
|
|
}
|
|
void runSearch(trimmed)
|
|
}
|
|
|
|
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
|
|
|
const updateServiceStatus = (name: string, status: string, message?: string) => {
|
|
setServicesStatus((prev) => {
|
|
if (!prev) return prev
|
|
return {
|
|
...prev,
|
|
services: prev.services.map((service) =>
|
|
service.name === name ? { ...service, status, message } : service
|
|
),
|
|
}
|
|
})
|
|
}
|
|
|
|
const testService = async (name: string) => {
|
|
const slug = toServiceSlug(name)
|
|
setServiceTesting((prev) => ({ ...prev, [name]: true }))
|
|
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
|
|
method: 'POST',
|
|
})
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
const text = await response.text()
|
|
throw new Error(text || `Service test failed: ${response.status}`)
|
|
}
|
|
const data = await response.json()
|
|
const status = data?.status ?? 'unknown'
|
|
const message =
|
|
data?.message ||
|
|
(status === 'up'
|
|
? 'API OK'
|
|
: status === 'down'
|
|
? 'API unreachable'
|
|
: status === 'degraded'
|
|
? 'Health warnings'
|
|
: status === 'not_configured'
|
|
? 'Not configured'
|
|
: 'Unknown')
|
|
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
|
|
updateServiceStatus(name, status, data?.message)
|
|
} catch (error) {
|
|
console.error(error)
|
|
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
|
|
} finally {
|
|
setServiceTesting((prev) => ({ ...prev, [name]: false }))
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
const load = async () => {
|
|
setRecentLoading(true)
|
|
setRecentError(null)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const meResponse = await authFetch(`${baseUrl}/auth/me`)
|
|
if (!meResponse.ok) {
|
|
if (meResponse.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
throw new Error(`Auth failed: ${meResponse.status}`)
|
|
}
|
|
const me = await meResponse.json()
|
|
const userRole = me?.role ?? null
|
|
setRole(userRole)
|
|
setAuthReady(true)
|
|
const take = userRole === 'admin' ? 50 : 6
|
|
const response = await authFetch(
|
|
`${baseUrl}/requests/recent?take=${take}&days=${recentDays}`
|
|
)
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
throw new Error(`Recent requests failed: ${response.status}`)
|
|
}
|
|
const data = await response.json()
|
|
if (Array.isArray(data?.results)) {
|
|
setRecent(
|
|
data.results
|
|
.filter((item: any) => item?.id)
|
|
.map((item: any) => {
|
|
const id = item.id
|
|
const rawTitle = item.title
|
|
const placeholder =
|
|
typeof rawTitle === 'string' &&
|
|
rawTitle.trim().toLowerCase() === `request ${id}`
|
|
return {
|
|
id,
|
|
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
|
|
year: item.year,
|
|
statusLabel: item.statusLabel,
|
|
artwork: item.artwork,
|
|
}
|
|
})
|
|
)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
setRecentError('Recent requests are not available right now.')
|
|
} finally {
|
|
setRecentLoading(false)
|
|
}
|
|
}
|
|
|
|
load()
|
|
}, [recentDays])
|
|
|
|
useEffect(() => {
|
|
if (!authReady) {
|
|
return
|
|
}
|
|
const load = async () => {
|
|
setServicesLoading(true)
|
|
setServicesError(null)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(`${baseUrl}/status/services`)
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
throw new Error(`Service status failed: ${response.status}`)
|
|
}
|
|
const data = await response.json()
|
|
setServicesStatus(data)
|
|
} catch (error) {
|
|
console.error(error)
|
|
setServicesError('Service status is not available right now.')
|
|
} finally {
|
|
setServicesLoading(false)
|
|
}
|
|
}
|
|
|
|
load()
|
|
const timer = setInterval(load, 30000)
|
|
return () => clearInterval(timer)
|
|
}, [authReady, router])
|
|
|
|
const runSearch = async (term: string) => {
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`)
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
throw new Error(`Search failed: ${response.status}`)
|
|
}
|
|
const data = await response.json()
|
|
if (Array.isArray(data?.results)) {
|
|
setSearchResults(
|
|
data.results.map((item: any) => ({
|
|
title: item.title,
|
|
year: item.year,
|
|
type: item.type,
|
|
requestId: item.requestId,
|
|
statusLabel: item.statusLabel,
|
|
}))
|
|
)
|
|
setSearchError(null)
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
setSearchError('Search failed. Try a request ID instead.')
|
|
setSearchResults([])
|
|
}
|
|
}
|
|
|
|
const resolveArtworkUrl = (url?: string | null) => {
|
|
if (!url) return null
|
|
return url.startsWith('http') ? url : `${getApiBase()}${url}`
|
|
}
|
|
|
|
return (
|
|
<main className="card">
|
|
<div className="layout-grid">
|
|
<section className="recent centerpiece">
|
|
<div className="system-status">
|
|
<div className="system-header">
|
|
<h2>System status</h2>
|
|
<span
|
|
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
|
|
>
|
|
{servicesLoading
|
|
? 'Checking services...'
|
|
: servicesError
|
|
? 'Status not available yet'
|
|
: servicesStatus?.overall === 'up'
|
|
? 'Services are up and running'
|
|
: servicesStatus?.overall === 'down'
|
|
? 'Something is down'
|
|
: 'Some services need attention'}
|
|
</span>
|
|
</div>
|
|
<div className="system-list">
|
|
{(() => {
|
|
const order = [
|
|
'Jellyseerr',
|
|
'Sonarr',
|
|
'Radarr',
|
|
'Prowlarr',
|
|
'qBittorrent',
|
|
'Jellyfin',
|
|
]
|
|
const items = servicesStatus?.services ?? []
|
|
return order.map((name) => {
|
|
const item = items.find((entry) => entry.name === name)
|
|
const status = item?.status ?? 'unknown'
|
|
const testing = serviceTesting[name] ?? false
|
|
return (
|
|
<div key={name} className={`system-item system-${status}`}>
|
|
<span className="system-dot" />
|
|
<div className="system-meta">
|
|
<span className="system-name">{name}</span>
|
|
{serviceTestResults[name] && (
|
|
<span className="system-test-message">{serviceTestResults[name]}</span>
|
|
)}
|
|
</div>
|
|
<div className="system-actions">
|
|
<span className="system-state">
|
|
{status === 'up'
|
|
? 'Up'
|
|
: status === 'down'
|
|
? 'Down'
|
|
: status === 'degraded'
|
|
? 'Needs attention'
|
|
: status === 'not_configured'
|
|
? 'Not configured'
|
|
: 'Unknown'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="system-test"
|
|
onClick={() => void testService(name)}
|
|
disabled={testing}
|
|
>
|
|
{testing ? 'Testing...' : 'Test'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
})()}
|
|
</div>
|
|
</div>
|
|
<div className="recent-header">
|
|
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
|
{authReady && (
|
|
<label className="recent-filter">
|
|
<span>Show last</span>
|
|
<select
|
|
value={recentDays}
|
|
onChange={(event) => setRecentDays(Number(event.target.value))}
|
|
>
|
|
<option value={30}>30 days</option>
|
|
<option value={60}>60 days</option>
|
|
<option value={90}>90 days</option>
|
|
<option value={180}>180 days</option>
|
|
</select>
|
|
</label>
|
|
)}
|
|
</div>
|
|
<div className="recent-grid">
|
|
{recentLoading ? (
|
|
<div className="loading-center">
|
|
<div className="spinner" aria-hidden="true" />
|
|
<span className="loading-text">Loading recent requests…</span>
|
|
</div>
|
|
) : recentError ? (
|
|
<button type="button" disabled>
|
|
{recentError}
|
|
</button>
|
|
) : recent.length === 0 ? (
|
|
<button type="button" disabled>
|
|
No recent requests found
|
|
</button>
|
|
) : (
|
|
recent.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => router.push(`/requests/${item.id}`)}
|
|
className="recent-card"
|
|
>
|
|
{item.artwork?.poster_url && (
|
|
<img
|
|
className="recent-poster"
|
|
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
|
|
alt=""
|
|
loading="lazy"
|
|
/>
|
|
)}
|
|
<span className="recent-info">
|
|
<span className="recent-title">
|
|
{item.title || 'Untitled'}
|
|
{item.year ? ` (${item.year})` : ''}
|
|
</span>
|
|
<span className="recent-meta">
|
|
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
|
|
{item.id}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
<aside className="side-panel">
|
|
<section className="main-panel find-panel">
|
|
<div className="find-header">
|
|
<h1>Find my request</h1>
|
|
<p className="lede">
|
|
Search by title + year, paste a request number, or pick from your recent requests.
|
|
</p>
|
|
</div>
|
|
<div className="find-controls">
|
|
<form onSubmit={submit} className="search search-row">
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="e.g. Dune 2021 or 1289"
|
|
/>
|
|
<button type="submit">Check status</button>
|
|
</form>
|
|
<div className="filters filters-compact">
|
|
<div className="filter">
|
|
<span>Type</span>
|
|
<div className="pill-group">
|
|
<button type="button">TV</button>
|
|
<button type="button">Movie</button>
|
|
</div>
|
|
</div>
|
|
<div className="filter">
|
|
<span>Status</span>
|
|
<div className="pill-group">
|
|
<button type="button">Pending</button>
|
|
<button type="button">Approved</button>
|
|
<button type="button">Processing</button>
|
|
<button type="button">Failed</button>
|
|
<button type="button">Available</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<section className="recent results-panel">
|
|
<h2>Search results</h2>
|
|
<div className="recent-grid">
|
|
{searchError ? (
|
|
<button type="button" disabled>
|
|
{searchError}
|
|
</button>
|
|
) : searchResults.length === 0 ? (
|
|
<button type="button" disabled>
|
|
No matches yet
|
|
</button>
|
|
) : (
|
|
searchResults.map((item, index) => (
|
|
<button
|
|
key={`${item.title || 'Untitled'}-${index}`}
|
|
type="button"
|
|
disabled={!item.requestId}
|
|
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
|
|
>
|
|
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
|
|
{!item.requestId
|
|
? '- not requested'
|
|
: item.statusLabel
|
|
? `- ${item.statusLabel}`
|
|
: ''}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|