Initial commit

This commit is contained in:
2026-01-22 22:49:57 +13:00
commit fe43a81175
67 changed files with 9408 additions and 0 deletions

372
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,372 @@
'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 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)
}
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'
return (
<div key={name} className={`system-item system-${status}`}>
<span className="system-dot" />
<span className="system-name">{name}</span>
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
</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>
)
}