feat: simplify ready requests and add global search
This commit is contained in:
@@ -132,6 +132,17 @@ export default function NewRequestClient() {
|
||||
if (!getToken()) router.push('/login')
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const requestedType = params.get('type')
|
||||
const requestedQuery = params.get('query')?.trim()
|
||||
if ((requestedType === 'movie' || requestedType === 'tv') && requestedQuery) {
|
||||
setMediaType(requestedType)
|
||||
setQuery(requestedQuery)
|
||||
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 80)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectedTitleId = selected?.tmdbId
|
||||
useEffect(() => {
|
||||
if (selectedTitleId) configureSectionRef.current?.focus()
|
||||
|
||||
@@ -3541,6 +3541,196 @@ textarea:focus {
|
||||
.config-subsection-nav { top: 62px; }
|
||||
}
|
||||
|
||||
/* Global title search */
|
||||
.header-nav {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-nav .header-actions { flex: 0 0 auto; width: auto; }
|
||||
.global-search {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
flex: 0 1 240px;
|
||||
width: 240px;
|
||||
min-width: 170px;
|
||||
}
|
||||
.global-search form {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.global-search form > svg {
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
fill: none;
|
||||
stroke: var(--ops-muted);
|
||||
stroke-linecap: round;
|
||||
stroke-width: 1.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
.global-search input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 7px 34px 7px 36px;
|
||||
border: 1px solid var(--ops-line);
|
||||
border-radius: 8px;
|
||||
background: var(--ops-panel-2);
|
||||
color: var(--ops-text);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.global-search input:focus {
|
||||
border-color: rgba(126, 215, 255, 0.58);
|
||||
outline: 2px solid rgba(14, 165, 233, 0.16);
|
||||
}
|
||||
.global-search input::placeholder { color: var(--ops-muted); }
|
||||
.global-search-spinner {
|
||||
position: absolute;
|
||||
right: 11px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--ops-line);
|
||||
border-top-color: var(--ops-primary-2);
|
||||
border-radius: 50%;
|
||||
animation: request-operation-spin 0.75s linear infinite;
|
||||
}
|
||||
.global-search-results {
|
||||
position: absolute;
|
||||
inset: calc(100% + 8px) 0 auto;
|
||||
display: grid;
|
||||
max-height: min(440px, calc(100vh - 92px));
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--ops-line);
|
||||
border-radius: 10px;
|
||||
background: #1c1b1d;
|
||||
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.46);
|
||||
}
|
||||
.global-search-results button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 9px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--ops-text);
|
||||
text-align: left;
|
||||
}
|
||||
.global-search-results button:hover,
|
||||
.global-search-results button:focus-visible { background: var(--ops-panel-3); }
|
||||
.global-search-results button > span { display: grid; min-width: 0; gap: 2px; }
|
||||
.global-search-results strong { overflow: hidden; font-size: 0.8rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.global-search-results small { color: var(--ops-muted); font-size: 0.66rem; }
|
||||
.global-search-results b { flex: 0 0 auto; color: var(--ops-primary-2); font-size: 0.63rem; }
|
||||
.global-search-results p { margin: 0; padding: 14px 10px; color: var(--ops-muted); font-size: 0.75rem; }
|
||||
|
||||
/* The finished request becomes a compact watch-or-report destination. */
|
||||
.request-next-step.is-ready { padding: 0; }
|
||||
.request-ready-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.request-ready-actions > section {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
.request-ready-actions > section + section { border-left: 1px solid var(--ops-line-soft); }
|
||||
.request-ready-actions > section > strong { color: var(--ops-text); font-size: 1.12rem; }
|
||||
.request-ready-actions > section > p { min-height: 44px; margin: 0; color: var(--ops-muted); line-height: 1.5; }
|
||||
.request-ready-actions .request-watch-button,
|
||||
.request-problem-button {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.request-problem-button {
|
||||
border: 1px solid rgba(126, 215, 255, 0.36);
|
||||
border-radius: var(--ops-radius);
|
||||
background: rgba(79, 70, 229, 0.18);
|
||||
color: var(--ops-text);
|
||||
font-weight: 750;
|
||||
}
|
||||
.request-problem-button:hover { border-color: rgba(126, 215, 255, 0.7); background: rgba(79, 70, 229, 0.28); }
|
||||
.request-ready-unavailable { margin-top: 5px; padding: 12px; border: 1px solid var(--ops-line); border-radius: var(--ops-radius); color: var(--ops-muted); font-size: 0.75rem; }
|
||||
|
||||
.request-issue-dialog {
|
||||
width: min(620px, calc(100vw - 28px));
|
||||
max-height: min(760px, calc(100vh - 28px));
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ops-line);
|
||||
border-radius: 12px;
|
||||
background: #201f21;
|
||||
color: var(--ops-text);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.58);
|
||||
}
|
||||
.request-issue-dialog::backdrop { background: rgba(5, 5, 8, 0.76); backdrop-filter: blur(5px); }
|
||||
.request-issue-dialog form { display: grid; gap: 18px; padding: 22px; }
|
||||
.request-issue-dialog form > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.request-issue-dialog h2 { margin: 3px 0 0; font-size: 1.55rem; }
|
||||
.request-issue-media { display: grid; gap: 4px; padding: 14px; border: 1px solid var(--ops-line); border-radius: 9px; background: var(--ops-panel-2); }
|
||||
.request-issue-media > span,
|
||||
.request-issue-media > small { color: var(--ops-muted); font-size: 0.7rem; }
|
||||
.request-issue-media > strong { font-size: 1rem; }
|
||||
.request-issue-dialog label { display: grid; gap: 8px; color: var(--ops-text); font-size: 0.8rem; font-weight: 750; }
|
||||
.request-issue-dialog textarea { width: 100%; resize: vertical; border-color: var(--ops-line); background: var(--ops-panel-2); color: var(--ops-text); line-height: 1.5; }
|
||||
.request-issue-help { margin: -10px 0 0; color: var(--ops-muted); font-size: 0.72rem; }
|
||||
.request-issue-dialog footer { display: flex; justify-content: flex-end; }
|
||||
.request-issue-dialog footer > button,
|
||||
.request-issue-success a { min-height: 42px; padding: 10px 18px; }
|
||||
.request-issue-success { display: grid; justify-items: center; gap: 12px; padding: 34px 24px; text-align: center; }
|
||||
.request-issue-success > span { display: grid; width: 52px; height: 52px; place-items: center; border: 1px solid rgba(72, 224, 178, 0.5); border-radius: 50%; color: var(--request-green); font-size: 1.5rem; }
|
||||
.request-issue-success h2,
|
||||
.request-issue-success p { margin: 0; }
|
||||
.request-issue-success p { max-width: 50ch; color: var(--ops-muted); line-height: 1.55; }
|
||||
.request-issue-success > div { display: flex; gap: 10px; margin-top: 8px; }
|
||||
.request-issue-success a { display: inline-flex; align-items: center; border-radius: var(--ops-radius); background: var(--ops-primary); color: var(--ops-primary-2); font-weight: 750; text-decoration: none; }
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.global-search { flex-basis: 190px; width: 190px; }
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.header { height: 108px; grid-template-rows: 58px 50px; }
|
||||
.header-left,
|
||||
.header-right { grid-row: 1; }
|
||||
.header-nav {
|
||||
display: flex;
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
width: 100%;
|
||||
}
|
||||
.header-nav .header-actions { display: none; }
|
||||
.global-search { flex: 1 1 auto; width: 100%; max-width: none; }
|
||||
.global-search-results { max-height: min(420px, calc(100vh - 190px)); }
|
||||
.page { padding-top: 108px; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.header { height: 104px; grid-template-rows: 54px 50px; }
|
||||
.page { padding-top: 104px; }
|
||||
.global-search input { height: 38px; }
|
||||
.request-ready-actions { grid-template-columns: 1fr; }
|
||||
.request-ready-actions > section + section { border-top: 1px solid var(--ops-line-soft); border-left: 0; }
|
||||
.request-ready-actions > section > p { min-height: 0; }
|
||||
.request-issue-dialog form { padding: 18px; }
|
||||
.request-issue-success > div { width: 100%; flex-direction: column; }
|
||||
.request-issue-success a { justify-content: center; }
|
||||
}
|
||||
|
||||
/* Guided issue reporting */
|
||||
.issue-portal-page {
|
||||
display: grid;
|
||||
|
||||
@@ -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,23 +820,29 @@ 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-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>
|
||||
) : <>
|
||||
<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>
|
||||
{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) => (
|
||||
@@ -839,16 +850,9 @@ export default function RequestTimelinePage() {
|
||||
{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>
|
||||
<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">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation'
|
||||
import BrandingLogo from './BrandingLogo'
|
||||
import HeaderActions from './HeaderActions'
|
||||
import HeaderIdentity from './HeaderIdentity'
|
||||
import GlobalSearch from './GlobalSearch'
|
||||
import SiteStatus from './SiteStatus'
|
||||
import UserViewBanner from './UserViewBanner'
|
||||
import WorkspaceNavigation from './WorkspaceNavigation'
|
||||
@@ -15,7 +16,7 @@ export default function ApplicationChrome() {
|
||||
<header className="header">
|
||||
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
|
||||
<div className="header-right"><span className="beta-chip" title="Beta environment">Beta</span><HeaderIdentity /></div>
|
||||
<div className="header-nav"><HeaderActions /></div>
|
||||
<div className="header-nav"><GlobalSearch /><HeaderActions /></div>
|
||||
</header>
|
||||
<WorkspaceNavigation />
|
||||
<UserViewBanner />
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../lib/auth'
|
||||
import { canAccess } from '../lib/features'
|
||||
import { useFeatureUser } from './FeatureGate'
|
||||
|
||||
type SearchResult = {
|
||||
title: string
|
||||
year?: number | null
|
||||
type: 'movie' | 'tv'
|
||||
tmdbId: number
|
||||
requestId?: number | null
|
||||
statusLabel?: string | null
|
||||
}
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const router = useRouter()
|
||||
const { user, ready } = useFeatureUser()
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const requestVersion = useRef(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const canSearch = canAccess(user, 'new_requests') || canAccess(user, 'issues')
|
||||
const canOpenRequests = canAccess(user, 'requests')
|
||||
const canCreateRequests = canAccess(user, 'new_requests')
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => {
|
||||
if (!root.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', close)
|
||||
return () => document.removeEventListener('pointerdown', close)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const term = query.trim()
|
||||
requestVersion.current += 1
|
||||
const version = requestVersion.current
|
||||
if (term.length < 2 || !canSearch) {
|
||||
setResults([])
|
||||
setSearching(false)
|
||||
setMessage(null)
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
setMessage(null)
|
||||
const controller = new AbortController()
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ query: term })
|
||||
const response = await authFetch(`${getApiBase()}/requests/search?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok) throw new Error('Search is unavailable right now.')
|
||||
const payload = await response.json()
|
||||
if (version !== requestVersion.current) return
|
||||
const mapped = (Array.isArray(payload?.results) ? payload.results : [])
|
||||
.filter((item: any) => ['movie', 'tv'].includes(item?.type) && Number(item?.tmdbId) > 0)
|
||||
.slice(0, 7)
|
||||
.map((item: any): SearchResult => ({
|
||||
title: String(item?.title || 'Untitled'),
|
||||
year: typeof item?.year === 'number' ? item.year : null,
|
||||
type: item.type,
|
||||
tmdbId: Number(item.tmdbId),
|
||||
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
|
||||
statusLabel: typeof item?.statusLabel === 'string' ? item.statusLabel : null,
|
||||
}))
|
||||
setResults(mapped)
|
||||
setMessage(mapped.length ? null : 'No matching titles found.')
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || version !== requestVersion.current) return
|
||||
console.error(error)
|
||||
setResults([])
|
||||
setMessage('Search is unavailable right now.')
|
||||
} finally {
|
||||
if (version === requestVersion.current) setSearching(false)
|
||||
}
|
||||
}, 280)
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [query, canSearch])
|
||||
|
||||
if (!ready || !user || !canSearch) return null
|
||||
|
||||
const openResult = (result: SearchResult) => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
if (result.requestId && canOpenRequests) {
|
||||
router.push(`/requests/${result.requestId}`)
|
||||
return
|
||||
}
|
||||
if (canCreateRequests) {
|
||||
const params = new URLSearchParams({ type: result.type, query: result.title })
|
||||
router.push(`/new-requests?${params.toString()}`)
|
||||
return
|
||||
}
|
||||
router.push('/portal/issues')
|
||||
}
|
||||
|
||||
const showResults = open && query.trim().length >= 2
|
||||
return (
|
||||
<div className="global-search" ref={root}>
|
||||
<search>
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (results[0]) openResult(results[0])
|
||||
}}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m16 16 4 4" /></svg>
|
||||
<input
|
||||
type="search"
|
||||
role="combobox"
|
||||
value={query}
|
||||
placeholder="Search titles or requests"
|
||||
aria-label="Search titles or requests"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={showResults}
|
||||
aria-controls="global-search-results"
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={(event) => { if (event.key === 'Escape') setOpen(false) }}
|
||||
onChange={(event) => { setQuery(event.target.value); setOpen(true) }}
|
||||
/>
|
||||
{searching && <i className="global-search-spinner" aria-hidden="true" />}
|
||||
</form>
|
||||
</search>
|
||||
{showResults && (
|
||||
<div className="global-search-results" id="global-search-results" role="listbox">
|
||||
{results.map((result) => (
|
||||
<button type="button" role="option" aria-selected="false" key={`${result.type}:${result.tmdbId}`} onClick={() => openResult(result)}>
|
||||
<span><strong>{result.title}</strong><small>{result.type === 'tv' ? 'TV show' : 'Movie'}{result.year ? ` · ${result.year}` : ''}</small></span>
|
||||
<b>{result.requestId && canOpenRequests ? result.statusLabel || 'View request' : canCreateRequests ? 'New request' : 'Report issue'}</b>
|
||||
</button>
|
||||
))}
|
||||
{!searching && message && <p role="status">{message}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,16 +36,6 @@ export default function HeaderActions() {
|
||||
]
|
||||
|
||||
const commonItems = [
|
||||
{
|
||||
href: '/insights',
|
||||
label: 'My Stats',
|
||||
match: (path: string) => path === '/insights' || path.startsWith('/insights/'),
|
||||
},
|
||||
{
|
||||
href: '/',
|
||||
label: 'My Requests',
|
||||
match: (path: string) => path === '/' || path.startsWith('/requests/'),
|
||||
},
|
||||
...(showRequestsNav
|
||||
? [
|
||||
{
|
||||
@@ -55,6 +45,16 @@ export default function HeaderActions() {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: '/',
|
||||
label: 'My Requests',
|
||||
match: (path: string) => path === '/' || path.startsWith('/requests/'),
|
||||
},
|
||||
{
|
||||
href: '/insights',
|
||||
label: 'My Stats',
|
||||
match: (path: string) => path === '/insights' || path.startsWith('/insights/'),
|
||||
},
|
||||
{
|
||||
href: '/portal/issues',
|
||||
label: 'Issues',
|
||||
|
||||
@@ -15,9 +15,9 @@ type NavigationItem = {
|
||||
}
|
||||
|
||||
const NAVIGATION: NavigationItem[] = [
|
||||
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' || path.startsWith('/insights/') },
|
||||
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
|
||||
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
|
||||
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
|
||||
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' || path.startsWith('/insights/') },
|
||||
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
|
||||
{ href: '/profile/invites', label: 'Invites', shortLabel: 'Invites', icon: 'invites', match: (path) => path.startsWith('/profile/invites') },
|
||||
{ href: '/admin', label: 'Configuration', shortLabel: 'Config', icon: 'settings', adminOnly: true, match: (path) => path.startsWith('/admin') || path.startsWith('/users') },
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function WelcomePage() {
|
||||
<header><span className="welcome-kicker">GrizzlyFlix + Magent</span><h1>Make yourself at home.</h1><p>Something to watch, or something to sort out?</p></header>
|
||||
{error ? <div role="alert"><p>{error}</p><button type="button" onClick={() => window.location.reload()}>Try again</button> <a href="/login">Back to sign in</a></div> : !ready ? <p role="status">Getting things ready…</p> : <div className="welcome-choices">
|
||||
{url ? <a className="welcome-choice" href={url}><span className="welcome-icon" aria-hidden="true">▷</span><h2>Go to GrizzlyFlix</h2><p>Find your next favourite. Watch movies and TV shows.</p><strong>Let’s watch <span aria-hidden="true">→</span></strong></a> : <section className="welcome-choice"><span className="welcome-icon" aria-hidden="true">▷</span><h2>Go to GrizzlyFlix</h2><p>The watch link hasn’t been set up yet. Please ask an admin to add the public playback URL.</p></section>}
|
||||
<a className="welcome-choice" href="/"><span className="welcome-icon" aria-hidden="true">☷</span><h2>Manage your account</h2><p>Track requests, report a problem, or update your profile.</p><strong>Open My Requests <span aria-hidden="true">→</span></strong></a>
|
||||
<a className="welcome-choice" href="/"><span className="welcome-icon" aria-hidden="true">☷</span><h2>View stats & requests</h2><p>Check your viewing stats, follow your requests, or make a new one.</p><strong>Open Magent <span aria-hidden="true">→</span></strong></a>
|
||||
</div>}
|
||||
<footer>First time here? <a href="/how-it-works">Here’s how it works</a>.</footer>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
|
||||
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
|
||||
const writes = [];
|
||||
const errors = [];
|
||||
await context.route('**/api/**', async route => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const reply = json => route.fulfill({ json });
|
||||
if (path === '/api/auth/me') return reply({ username: 'Viewer', role: 'user', features: { requests: true, new_requests: true, stats: true, issues: true, invites: true } });
|
||||
if (path === '/api/site/info') return reply({ mediaServerUrl: 'https://watch.example.test/' });
|
||||
if (path === '/api/requests/search') return reply({ results: [
|
||||
{ title: 'Drive', type: 'movie', tmdbId: 64690, year: 2011, requestId: 42, statusLabel: 'Ready to watch' },
|
||||
{ title: 'Drive Away Dolls', type: 'movie', tmdbId: 957304, year: 2024 },
|
||||
] });
|
||||
if (path === '/api/requests/42/snapshot') return reply({
|
||||
request_id: '42', title: 'Drive', year: 2011, request_type: 'movie', state: 'AVAILABLE', timeline: [], actions: [],
|
||||
presentation: {
|
||||
status: { label: 'Available to watch', meaning: 'Collection is complete and the title is available on the media server.' },
|
||||
nextStep: { title: 'Ready to watch', description: 'Open the title when you are ready.', actionIds: [] },
|
||||
pipeline: [{ id: 'available', label: 'Available to watch', state: 'complete', summary: 'Ready', link: 'https://watch.example.test/movie/drive' }],
|
||||
},
|
||||
});
|
||||
if (path === '/api/requests/42/language') return reply({ language: null });
|
||||
if (path === '/api/portal/items' && request.method() === 'POST') {
|
||||
writes.push(request.postDataJSON());
|
||||
return reply({ item: { id: 73 } });
|
||||
}
|
||||
return reply({});
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 950 });
|
||||
await page.goto(base + '/requests/42');
|
||||
await page.getByText('Watch this now!', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByRole('heading', { name: 'Where your request is now' }).count(), 0);
|
||||
assert.equal(await page.getByRole('link', { name: /Watch on Grizzlyflix/ }).getAttribute('href'), 'https://watch.example.test/movie/drive');
|
||||
|
||||
const search = page.getByRole('combobox', { name: 'Search titles or requests' });
|
||||
assert(await search.isVisible());
|
||||
await search.fill('Drive');
|
||||
await page.getByRole('option', { name: /Drive.*Ready to watch/ }).waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByRole('button', { name: "Tell us what's wrong" }).click();
|
||||
const dialog = page.getByRole('dialog', { name: "What's wrong?" });
|
||||
await dialog.getByText('Drive (2011)', { exact: true }).waitFor();
|
||||
await dialog.getByLabel('Tell us what happened').fill('The movie freezes at 42 minutes.');
|
||||
await dialog.getByRole('button', { name: 'Send report' }).click();
|
||||
await page.getByRole('heading', { name: "Thanks, we've got it" }).waitFor();
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
|
||||
if (width === 1440) {
|
||||
const labels = await page.locator('.header-actions a').allTextContents();
|
||||
assert(labels.indexOf('New Requests') < labels.indexOf('My Requests'));
|
||||
assert(labels.indexOf('My Requests') < labels.indexOf('My Stats'));
|
||||
}
|
||||
}
|
||||
|
||||
await page.goto(base + '/welcome');
|
||||
await page.getByRole('heading', { name: 'View stats & requests' }).waitFor();
|
||||
await page.getByText('Check your viewing stats, follow your requests, or make a new one.', { exact: true }).waitFor();
|
||||
|
||||
assert.equal(writes.length, 2);
|
||||
assert(writes.every(write => write.kind === 'issue' && write.title === 'Problem with Drive' && write.external_ref === '/requests/42' && write.description === 'The movie freezes at 42 minutes.'));
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('Passed: completed request actions, linked issue dialog, global search, navigation order, welcome copy, and desktop/mobile overflow. APIs intercepted.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user