Process 1 build 0803262216
This commit is contained in:
@@ -67,6 +67,19 @@ type UserProfile = {
|
||||
role: string
|
||||
}
|
||||
|
||||
type DiscoveryResult = {
|
||||
title: string
|
||||
year?: number | null
|
||||
type?: 'movie' | 'tv' | null
|
||||
tmdbId?: number | null
|
||||
requestId?: number | null
|
||||
statusLabel?: string | null
|
||||
status?: number | null
|
||||
accessible?: boolean
|
||||
posterPath?: string | null
|
||||
backdropPath?: string | null
|
||||
}
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'request', label: 'Request' },
|
||||
{ value: 'issue', label: 'Issue' },
|
||||
@@ -176,6 +189,11 @@ export default function PortalPage() {
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [commentInternal, setCommentInternal] = useState(false)
|
||||
const [preselectedItemId, setPreselectedItemId] = useState<number | null>(null)
|
||||
const [discoverQuery, setDiscoverQuery] = useState('')
|
||||
const [discoverLoading, setDiscoverLoading] = useState(false)
|
||||
const [discoverResults, setDiscoverResults] = useState<DiscoveryResult[]>([])
|
||||
const [discoverError, setDiscoverError] = useState<string | null>(null)
|
||||
const [requestingTmdbIds, setRequestingTmdbIds] = useState<Record<string, boolean>>({})
|
||||
|
||||
const isAdmin = me?.role === 'admin'
|
||||
|
||||
@@ -306,6 +324,123 @@ export default function PortalPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTmdbArtworkUrl = (path?: string | null, size: 'w185' | 'w342' = 'w185') => {
|
||||
if (!path) return null
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
return `https://image.tmdb.org/t/p/${size}${normalized}`
|
||||
}
|
||||
|
||||
const runDiscoverySearch = async (event?: React.FormEvent) => {
|
||||
if (event) event.preventDefault()
|
||||
const query = discoverQuery.trim()
|
||||
if (!query) {
|
||||
setDiscoverResults([])
|
||||
setDiscoverError('Enter a title to search.')
|
||||
return
|
||||
}
|
||||
setDiscoverLoading(true)
|
||||
setDiscoverError(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(query)}`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Search failed (${response.status})`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const mapped: DiscoveryResult[] = Array.isArray(data?.results)
|
||||
? data.results.map((item: any) => ({
|
||||
title: item?.title ?? 'Untitled',
|
||||
year: typeof item?.year === 'number' ? item.year : null,
|
||||
type: item?.type === 'movie' || item?.type === 'tv' ? item.type : null,
|
||||
tmdbId: typeof item?.tmdbId === 'number' ? item.tmdbId : null,
|
||||
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
|
||||
statusLabel: item?.statusLabel ?? null,
|
||||
status: typeof item?.status === 'number' ? item.status : null,
|
||||
accessible: Boolean(item?.accessible),
|
||||
posterPath: item?.posterPath ?? null,
|
||||
backdropPath: item?.backdropPath ?? null,
|
||||
}))
|
||||
: []
|
||||
setDiscoverResults(mapped)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setDiscoverResults([])
|
||||
setDiscoverError(err instanceof Error ? err.message : 'Search failed.')
|
||||
} finally {
|
||||
setDiscoverLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const requestDiscoveryItem = async (item: DiscoveryResult) => {
|
||||
if (!item.tmdbId || !item.type) {
|
||||
setError('Could not request this result because required media details are missing.')
|
||||
return
|
||||
}
|
||||
const key = `${item.type}:${item.tmdbId}`
|
||||
setRequestingTmdbIds((prev) => ({ ...prev, [key]: true }))
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/requests/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mediaType: item.type,
|
||||
tmdbId: item.tmdbId,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Request failed (${response.status})`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const requestId = typeof data?.requestId === 'number' ? data.requestId : null
|
||||
const statusLabel = typeof data?.statusLabel === 'string' ? data.statusLabel : item.statusLabel
|
||||
const statusCode = typeof data?.statusCode === 'number' ? data.statusCode : item.status
|
||||
setDiscoverResults((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.tmdbId === item.tmdbId && entry.type === item.type
|
||||
? {
|
||||
...entry,
|
||||
requestId,
|
||||
statusLabel,
|
||||
status: statusCode,
|
||||
accessible: true,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
)
|
||||
if (requestId) {
|
||||
const mode = data?.status === 'exists' ? 'already exists' : 'created'
|
||||
setStatus(`Request ${mode}. Open request #${requestId} for the full pipeline.`)
|
||||
} else {
|
||||
setStatus('Request submitted.')
|
||||
}
|
||||
await Promise.all([loadItems(), loadOverview()])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Could not create request.')
|
||||
} finally {
|
||||
setRequestingTmdbIds((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
@@ -522,6 +657,85 @@ export default function PortalPage() {
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
|
||||
<section className="admin-panel portal-discovery-panel">
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>Search and request content</h2>
|
||||
<p className="lede">
|
||||
Search Seerr content directly, then submit a request in one click.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form className="portal-discovery-form" onSubmit={runDiscoverySearch}>
|
||||
<input
|
||||
value={discoverQuery}
|
||||
onChange={(event) => setDiscoverQuery(event.target.value)}
|
||||
placeholder="Search movies or TV shows"
|
||||
/>
|
||||
<button type="submit" disabled={discoverLoading}>
|
||||
{discoverLoading ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
</form>
|
||||
{discoverError && <div className="error-banner">{discoverError}</div>}
|
||||
<div className="portal-discovery-results">
|
||||
{discoverLoading ? (
|
||||
<div className="status-banner">Searching Seerr…</div>
|
||||
) : discoverResults.length === 0 ? (
|
||||
<div className="status-banner">No discovery results yet.</div>
|
||||
) : (
|
||||
discoverResults.map((item, index) => {
|
||||
const key = `${item.type ?? 'unknown'}:${item.tmdbId ?? index}`
|
||||
const requesting = Boolean(requestingTmdbIds[key])
|
||||
const poster = resolveTmdbArtworkUrl(item.posterPath, 'w185')
|
||||
const hasRequest = typeof item.requestId === 'number' && item.requestId > 0
|
||||
return (
|
||||
<div key={key} className="portal-discovery-item">
|
||||
<div className="portal-discovery-media">
|
||||
{poster ? <img src={poster} alt="" loading="lazy" /> : <div className="poster-fallback">No artwork</div>}
|
||||
</div>
|
||||
<div className="portal-discovery-main">
|
||||
<div className="portal-discovery-title-row">
|
||||
<strong>{item.title || 'Untitled'}</strong>
|
||||
<span className="small-pill">{item.type ?? 'unknown'}</span>
|
||||
{item.year ? <span className="small-pill is-muted">{item.year}</span> : null}
|
||||
</div>
|
||||
<p>
|
||||
{hasRequest ? (
|
||||
<>
|
||||
Already requested
|
||||
{item.statusLabel ? ` · ${item.statusLabel}` : ''}
|
||||
{item.requestId ? ` · #${item.requestId}` : ''}
|
||||
</>
|
||||
) : (
|
||||
'Not requested yet'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="portal-discovery-actions">
|
||||
{hasRequest ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/requests/${item.requestId}`)}
|
||||
>
|
||||
Open request
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void requestDiscoveryItem(item)}
|
||||
disabled={requesting || !item.tmdbId || !item.type}
|
||||
>
|
||||
{requesting ? 'Requesting…' : 'Request'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="portal-overview-grid">
|
||||
<div className="portal-overview-card">
|
||||
<span>Total items</span>
|
||||
|
||||
Reference in New Issue
Block a user