feat: simplify ready requests and add global search
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||
|
||||
type Props = {
|
||||
requestId: string
|
||||
title: string
|
||||
year?: number
|
||||
requestType: string
|
||||
}
|
||||
|
||||
const readError = async (response: Response) => {
|
||||
try {
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail
|
||||
} catch {
|
||||
// Use the friendly fallback below.
|
||||
}
|
||||
return 'Your report could not be submitted. Please try again.'
|
||||
}
|
||||
|
||||
export default function RequestIssueDialog({ requestId, title, year, requestType }: Props) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const detailsInput = useRef<HTMLTextAreaElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [details, setDetails] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [issueId, setIssueId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
dialog.current?.showModal()
|
||||
const unlock = lockBodyScroll()
|
||||
window.setTimeout(() => detailsInput.current?.focus(), 0)
|
||||
return () => {
|
||||
dialog.current?.close()
|
||||
unlock()
|
||||
previous?.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const close = () => {
|
||||
if (submitting) return
|
||||
setOpen(false)
|
||||
setError(null)
|
||||
if (issueId) {
|
||||
setIssueId(null)
|
||||
setDetails('')
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const description = details.trim()
|
||||
if (!description) {
|
||||
setError('Tell us what is wrong so we know what to check.')
|
||||
detailsInput.current?.focus()
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/portal/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'issue',
|
||||
title: `Problem with ${title}`,
|
||||
description,
|
||||
media_type: requestType === 'tv' ? 'tv' : 'movie',
|
||||
year: year ?? null,
|
||||
external_ref: `/requests/${requestId}`,
|
||||
issue_type: 'general',
|
||||
priority: 'normal',
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(await readError(response))
|
||||
const payload = await response.json()
|
||||
const createdId = Number(payload?.item?.id)
|
||||
if (!Number.isInteger(createdId) || createdId <= 0) throw new Error('The issue was created, but its reference could not be loaded.')
|
||||
setIssueId(createdId)
|
||||
} catch (caught) {
|
||||
console.error(caught)
|
||||
setError(caught instanceof Error ? caught.message : 'Your report could not be submitted. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<button type="button" className="request-problem-button" onClick={() => setOpen(true)}>Tell us what's wrong</button>
|
||||
<dialog ref={dialog} className="request-issue-dialog" aria-labelledby="request-issue-title" onCancel={(event) => { event.preventDefault(); close() }}>
|
||||
{issueId ? (
|
||||
<div className="request-issue-success" role="status">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<h2 id="request-issue-title">Thanks, we've got it</h2>
|
||||
<p>Your report is now issue #{issueId}. You can follow its progress from the Issues page.</p>
|
||||
<div><a href={`/portal/issues?item=${issueId}`}>View reported issue</a><button type="button" className="ghost-button" onClick={close}>Done</button></div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<header>
|
||||
<div><span className="section-kicker">Report a problem</span><h2 id="request-issue-title">What's wrong?</h2></div>
|
||||
<button type="button" className="ghost-button" onClick={close} disabled={submitting}>Close</button>
|
||||
</header>
|
||||
<div className="request-issue-media"><span>{requestType === 'tv' ? 'TV show' : 'Movie'}</span><strong>{title}{year ? ` (${year})` : ''}</strong><small>Request #{requestId} · Ready to watch</small></div>
|
||||
<label htmlFor="request-issue-details">Tell us what happened<textarea ref={detailsInput} id="request-issue-details" rows={6} maxLength={10000} value={details} onChange={(event) => setDetails(event.target.value)} placeholder="For example: it won’t play, the audio is wrong, or the wrong version is showing…" disabled={submitting} /></label>
|
||||
<p className="request-issue-help">The title and request details are included automatically.</p>
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
<footer><button type="submit" disabled={submitting || !details.trim()}>{submitting ? 'Sending report…' : 'Send report'}</button></footer>
|
||||
</form>
|
||||
)}
|
||||
</dialog>
|
||||
</>
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import PageHeading from '../../ui/PageHeading'
|
||||
import RequestLanguage from './RequestLanguage'
|
||||
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||
import LatestActivity from './LatestActivity'
|
||||
import RequestIssueDialog from './RequestIssueDialog'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||
import { canAccess } from '../../lib/features'
|
||||
|
||||
type TimelineHop = {
|
||||
service: string
|
||||
@@ -325,6 +327,7 @@ export default function RequestTimelinePage() {
|
||||
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
|
||||
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [canReportIssues, setCanReportIssues] = useState(false)
|
||||
const awaitingMediaIndex = Boolean(
|
||||
snapshot?.presentation?.pipeline?.some(
|
||||
(stage) => stage.id === 'available' && stage.state === 'active'
|
||||
@@ -386,6 +389,7 @@ export default function RequestTimelinePage() {
|
||||
const me = await meResponse.json()
|
||||
const viewerIsAdmin = me?.role === 'admin'
|
||||
setIsAdmin(viewerIsAdmin)
|
||||
setCanReportIssues(canAccess(me, 'issues'))
|
||||
if (!snapshotResponse.ok) {
|
||||
throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
|
||||
}
|
||||
@@ -537,6 +541,7 @@ export default function RequestTimelinePage() {
|
||||
const mediaServerLink = availableStage?.state === 'complete' && availableStage.link
|
||||
? availableStage.link
|
||||
: null
|
||||
const requestComplete = ['COMPLETED', 'AVAILABLE'].includes(snapshot.state) || availableStage?.state === 'complete'
|
||||
const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state)
|
||||
const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
|
||||
const download = presentation.download
|
||||
@@ -815,40 +820,39 @@ export default function RequestTimelinePage() {
|
||||
</div>
|
||||
)}
|
||||
{operationProgress && <LatestActivity operation={operationProgress} besideDownload={downloadVisible} onDismiss={() => setOperationProgress(null)} />}
|
||||
<div className="request-overview-block request-next-step">
|
||||
<div className="request-next-step-main">
|
||||
<div className="request-next-step-copy">
|
||||
<span className="request-overview-label">Next step</span>
|
||||
<strong>{nextStep.title}</strong>
|
||||
<p>{nextStep.description}</p>
|
||||
<div className={`request-overview-block request-next-step ${requestComplete ? 'is-ready' : ''}`}>
|
||||
{requestComplete ? (
|
||||
<div className="request-ready-actions">
|
||||
<section>
|
||||
<span className="request-overview-label">Ready to watch</span>
|
||||
<strong>Watch this now!</strong>
|
||||
<p>Open {snapshot.title} directly in Grizzlyflix.</p>
|
||||
{mediaServerLink ? <a className="request-watch-button" href={mediaServerLink} target="_blank" rel="noreferrer">Watch on Grizzlyflix <span aria-hidden="true">→</span></a> : <span className="request-ready-unavailable">The Grizzlyflix watch link is not configured.</span>}
|
||||
</section>
|
||||
<section>
|
||||
<span className="request-overview-label">Need help?</span>
|
||||
<strong>Is there a problem with this?</strong>
|
||||
<p>Let us know what is wrong and we'll attach the title and request details automatically.</p>
|
||||
{canReportIssues ? <RequestIssueDialog requestId={snapshot.request_id} title={snapshot.title} year={snapshot.year} requestType={snapshot.request_type} /> : <span className="request-ready-unavailable">Issue reporting is not enabled for your account.</span>}
|
||||
</section>
|
||||
</div>
|
||||
{mediaServerLink && (
|
||||
<a
|
||||
className="request-watch-button"
|
||||
href={mediaServerLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Watch on Grizzlyflix <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="request-action-row">
|
||||
{recommendedActions.map((action) => (
|
||||
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>
|
||||
{busyAction === action.id ? 'Working…' : action.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="request-recheck-button"
|
||||
disabled={Boolean(busyAction)}
|
||||
onClick={() => void recheckRequest()}
|
||||
title="Recheck Seerr, the library collector, qBittorrent, and the media server"
|
||||
>
|
||||
{busyAction === 'recheck_pipeline' ? 'Rechecking…' : 'Recheck request'}
|
||||
</button>
|
||||
</div>
|
||||
) : <>
|
||||
<div className="request-next-step-main">
|
||||
<div className="request-next-step-copy">
|
||||
<span className="request-overview-label">Next step</span>
|
||||
<strong>{nextStep.title}</strong>
|
||||
<p>{nextStep.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="request-action-row">
|
||||
{recommendedActions.map((action) => (
|
||||
<button key={action.id} type="button" disabled={Boolean(busyAction)} onClick={() => void runAction(action)}>
|
||||
{busyAction === action.id ? 'Working…' : action.label}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="request-recheck-button" disabled={Boolean(busyAction)} onClick={() => void recheckRequest()} title="Recheck Seerr, the library collector, qBittorrent, and the media server">{busyAction === 'recheck_pipeline' ? 'Rechecking…' : 'Recheck request'}</button>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
{(actionMessage || actionError) && (
|
||||
<div className={`request-action-feedback ${actionError ? 'is-error' : 'is-success'}`} role="status">
|
||||
@@ -886,7 +890,7 @@ export default function RequestTimelinePage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="request-journey" aria-labelledby="request-journey-heading">
|
||||
{!requestComplete && <section className="request-journey" aria-labelledby="request-journey-heading">
|
||||
<div className="request-journey-heading">
|
||||
<div>
|
||||
<span className="section-kicker">Live collection path</span>
|
||||
@@ -975,7 +979,7 @@ export default function RequestTimelinePage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</section>}
|
||||
|
||||
{releasePickerOpen && (
|
||||
<div className="request-release-modal-layer">
|
||||
|
||||
Reference in New Issue
Block a user