Process 1 build 0803262216

This commit is contained in:
2026-03-08 22:17:33 +13:00
parent 3989e90a9a
commit f830fc1296
8 changed files with 496 additions and 6 deletions

View File

@@ -6597,6 +6597,86 @@ textarea {
gap: 12px;
}
.portal-discovery-panel {
display: grid;
gap: 12px;
}
.portal-discovery-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px;
gap: 10px;
}
.portal-discovery-form input {
width: 100%;
}
.portal-discovery-results {
display: grid;
gap: 10px;
}
.portal-discovery-item {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
padding: 10px;
}
.portal-discovery-media {
width: 56px;
height: 84px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line);
}
.portal-discovery-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.portal-discovery-main {
display: grid;
gap: 6px;
}
.portal-discovery-title-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.portal-discovery-main p {
margin: 0;
font-size: 0.84rem;
color: var(--muted);
}
.portal-discovery-actions {
display: flex;
align-items: center;
}
.poster-fallback {
display: grid;
place-items: center;
width: 100%;
height: 100%;
color: var(--muted);
font-size: 0.66rem;
text-align: center;
padding: 4px;
}
.portal-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -6756,6 +6836,15 @@ textarea {
.portal-item-list {
max-height: 460px;
}
.portal-discovery-item {
grid-template-columns: 56px minmax(0, 1fr);
}
.portal-discovery-actions {
grid-column: span 2;
justify-content: flex-end;
}
}
@media (max-width: 760px) {
@@ -6776,4 +6865,22 @@ textarea {
.portal-mine-toggle {
grid-column: span 1;
}
.portal-discovery-form {
grid-template-columns: 1fr;
}
.portal-discovery-item {
grid-template-columns: 1fr;
}
.portal-discovery-media {
width: 72px;
height: 108px;
}
.portal-discovery-actions {
grid-column: span 1;
justify-content: flex-start;
}
}

View File

@@ -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>