Show request submission progress in a focused follow-up dialog
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import PageHeading from '../ui/PageHeading'
|
||||
import { lockBodyScroll } from '../lib/scrollLock'
|
||||
import './request-progress.css'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -96,12 +98,6 @@ const apiError = async (response: Response, fallback: string) => {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const formatDuration = (milliseconds?: number | null) => {
|
||||
if (milliseconds == null) return null
|
||||
if (milliseconds < 1000) return `${Math.round(milliseconds)} ms`
|
||||
return `${(milliseconds / 1000).toFixed(1)} s`
|
||||
}
|
||||
|
||||
export default function NewRequestClient() {
|
||||
const router = useRouter()
|
||||
const searchSectionRef = useRef<HTMLElement | null>(null)
|
||||
@@ -118,6 +114,16 @@ export default function NewRequestClient() {
|
||||
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([])
|
||||
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState<boolean | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [progressOpen, setProgressOpen] = useState(false)
|
||||
const progressDialog = useRef<HTMLDialogElement>(null)
|
||||
useEffect(() => {
|
||||
if (!progressOpen) return
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
progressDialog.current?.showModal()
|
||||
const unlock = lockBodyScroll()
|
||||
return () => { progressDialog.current?.close(); unlock(); previous?.focus() }
|
||||
}, [progressOpen])
|
||||
|
||||
const [operation, setOperation] = useState<OperationProgress | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
@@ -269,12 +275,13 @@ export default function NewRequestClient() {
|
||||
}
|
||||
|
||||
const submitRequest = async () => {
|
||||
if (!selected || !options) return
|
||||
if (!selected || !options || submitting) return
|
||||
if (options.media.originalLanguage && acceptOriginalLanguage === null) { setError('Choose an audio language option before requesting.'); return }
|
||||
if (selected.type === 'tv' && selectedSeasons.length === 0) {
|
||||
setError('Select at least one season.')
|
||||
return
|
||||
}
|
||||
setProgressOpen(true)
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
@@ -309,7 +316,7 @@ export default function NewRequestClient() {
|
||||
setResults((current) => current.map((item) => item.tmdbId === selected.tmdbId && item.type === selected.type
|
||||
? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
|
||||
: item))
|
||||
setSuccess(requestId ? `Request #${requestId} has been accepted by Seerr.` : 'Your request has been accepted by Seerr.')
|
||||
setSuccess('Your request has been received.')
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : 'The request could not be submitted.')
|
||||
} finally {
|
||||
@@ -336,6 +343,22 @@ export default function NewRequestClient() {
|
||||
|
||||
return (
|
||||
<main className="card request-portal-page">
|
||||
<dialog ref={progressDialog} className="create-request-dialog" aria-labelledby="create-progress-title" onCancel={() => setProgressOpen(false)} onClose={() => setProgressOpen(false)}>
|
||||
<div className="create-progress-header"><span>Request progress</span><button type="button" className="ghost-button" onClick={() => setProgressOpen(false)} aria-label="Close request progress">Close</button></div>
|
||||
<div className="create-progress-body" aria-live="polite" aria-atomic="true">
|
||||
{submitting && <span className="create-progress-spinner" aria-hidden="true" />}
|
||||
<h2 id="create-progress-title">{submitting ? 'Sending your request' : success ? 'Request received' : 'Your request needs attention'}</h2>
|
||||
<p className="create-progress-title">{selected?.title}</p>
|
||||
<p>{submitting ? (operation?.events.some(event => event.service === 'Sonarr' || event.service === 'Radarr') ? 'Setting up your title for collection. Please wait.' : 'Checking your selection and sending it to the request service. Please wait.') : success ? 'Your request is now in the pipeline. Follow it to see approval, download progress and when it is ready to watch.' : error || 'We could not confirm the result. Check My requests before trying again.'}</p>
|
||||
{submitting && <div className="create-progress-track" role="progressbar" aria-label="Submitting request"><span /></div>}
|
||||
{success && <div className="create-progress-stage"><span>Current stage</span><strong>{selected?.statusLabel || 'Request received'}</strong></div>}
|
||||
</div>
|
||||
<div className="create-progress-actions">
|
||||
{!submitting && <button type="button" className="create-progress-follow" onClick={() => router.push(selected?.requestId ? `/requests/${selected.requestId}` : '/')} >{success ? 'Follow your request' : 'Check My requests'} <span aria-hidden="true">→</span></button>}
|
||||
{!submitting && <button type="button" className="ghost-button" onClick={() => setProgressOpen(false)}>{success ? 'Back to browsing' : 'Back to request'}</button>}
|
||||
{submitting && <small>You can close this window. Submission will continue while you stay on this page.</small>}
|
||||
</div>
|
||||
</dialog>
|
||||
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
|
||||
|
||||
<ol className="request-master-stepper" aria-label="New request progress">
|
||||
@@ -513,20 +536,10 @@ export default function NewRequestClient() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{operation && (
|
||||
<div className={`request-submit-progress is-${operation.status}`} aria-live="polite">
|
||||
<header><div><span>Remote activity</span><strong>{operation.status === 'running' ? 'Sending your request' : operation.status === 'complete' ? 'Request hand-off complete' : 'Request hand-off needs attention'}</strong></div>{formatDuration(operation.duration_ms) && <small>{formatDuration(operation.duration_ms)}</small>}</header>
|
||||
<div>
|
||||
{operation.events.map((event) => (
|
||||
<p key={event.id} className={`is-${event.state}`}><i aria-hidden="true" /><span><strong>{event.service}</strong>{event.message}</span><small>{formatDuration(event.duration_ms)}{event.status_code ? ` · HTTP ${event.status_code}` : ''}</small></p>
|
||||
))}
|
||||
{operation.events.length === 0 && <p className="is-active"><i aria-hidden="true" /><span><strong>Magent</strong>Preparing the request…</span></p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{operation && <button type="button" className="ghost-button" onClick={() => setProgressOpen(true)}>View request progress</button>}
|
||||
|
||||
{success && selected.requestId && (
|
||||
<div className="request-complete-actions"><button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Track request #{selected.requestId}</button><button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>Request something else</button></div>
|
||||
<div className="request-complete-actions"><button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>Follow your request</button><button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>Request something else</button></div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.create-request-dialog { width:min(560px,calc(100vw - 32px)); max-height:90dvh; overflow:auto; padding:28px; border:1px solid #514d62; border-radius:20px; background:#1b1b22; color:#eeeaf5; box-shadow:0 30px 100px #0009; }
|
||||
.create-request-dialog::backdrop { background:#080910c9; backdrop-filter:blur(6px); }
|
||||
.create-progress-header { display:flex; align-items:center; justify-content:space-between; gap:16px; color:#79e0eb; font-size:13px; }
|
||||
.create-progress-body { padding:24px 0; }
|
||||
.create-progress-body h2 { font-size:clamp(25px,4vw,34px); margin:12px 0; }
|
||||
.create-progress-body p { color:#c3bfce; line-height:1.7; overflow-wrap:anywhere; }
|
||||
.create-progress-body .create-progress-title { font-size:20px; color:#fff; font-weight:600; }
|
||||
.create-progress-spinner { display:block; width:42px; height:42px; border:4px solid #ffffff20; border-top-color:#70e0e4; border-radius:50%; animation:create-spin .8s linear infinite; }
|
||||
.create-progress-track { height:6px; background:#ffffff15; overflow:hidden; border-radius:6px; margin-top:24px; }
|
||||
.create-progress-track span { display:block; width:35%; height:100%; background:#8edee5; animation:create-track 1.5s ease-in-out infinite alternate; }
|
||||
.create-progress-stage { display:grid; gap:8px; border:1px solid #4c536a; border-radius:12px; padding:18px; background:#242938; }
|
||||
.create-progress-stage span { color:#b9b6c6; font-size:12px; }
|
||||
.create-progress-actions { display:grid; gap:12px; }
|
||||
.create-progress-actions .create-progress-follow { padding:18px; background:#c5b8ff; color:#191629; font-size:18px; font-weight:700; border-radius:12px; }
|
||||
.create-progress-actions small { color:#b9b6c6; line-height:1.6; }
|
||||
@keyframes create-spin { to { transform:rotate(360deg); } }
|
||||
@keyframes create-track { to { transform:translateX(185%); } }
|
||||
@media(prefers-reduced-motion:reduce) { .create-progress-spinner,.create-progress-track span { animation:none; } }
|
||||
@@ -14,7 +14,7 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||
if (path.startsWith('/api/operations/')) return route.fulfill({ json: { status: 'complete', events: [] } });
|
||||
if (path === '/api/requests/search') return route.fulfill({ json: { results: [media] } });
|
||||
if (path === '/api/requests/request-options') return route.fulfill({ json: { media, destination: { collector: 'Radarr', serverName: 'Movies', defaultProfileId: 6, profiles: [] } } });
|
||||
if (path === '/api/requests/create') { writes.push(route.request().postDataJSON()); return route.fulfill({ json: { requestId: 12 } }); }
|
||||
if (path === '/api/requests/create') { writes.push(route.request().postDataJSON()); await new Promise(resolve => setTimeout(resolve, 1200)); return route.fulfill({ json: { requestId: 12 } }); }
|
||||
return route.fulfill({ json: {} });
|
||||
});
|
||||
const page = await context.newPage();
|
||||
@@ -39,7 +39,17 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||
await consent.check();
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
await page.getByRole('button', { name: 'Request movie', exact: true }).click();
|
||||
await page.getByText('Request #12 has been accepted by Seerr.', { exact: true }).waitFor();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('heading', { name: 'Sending your request' }).waitFor();
|
||||
assert(await dialog.getByRole('progressbar').isVisible());
|
||||
await dialog.getByRole('heading', { name: 'Request received', exact: true }).waitFor();
|
||||
assert(await dialog.getByRole('button', { name: 'Follow your request' }).isVisible());
|
||||
assert(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth));
|
||||
await page.keyboard.press('Escape');
|
||||
assert(!await dialog.isVisible());
|
||||
await page.getByRole('button', { name: 'View request progress' }).click();
|
||||
await dialog.getByRole('button', { name: 'Follow your request' }).click();
|
||||
await page.waitForURL('**/requests/12');
|
||||
}
|
||||
assert.equal(writes.length, 2);
|
||||
assert(writes.every(w => w.acceptOriginalLanguage === true && w.tmdbId === 1417 && !w.profileId));
|
||||
|
||||
Reference in New Issue
Block a user