Files
Magent/frontend/app/ui/GlobalSearch.tsx
T
Assclaw 4ba1a5763e
Magent CI/CD / verify (push) Successful in 1m56s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 48s
feat: simplify ready requests and add global search
2026-09-15 15:15:35 +12:00

147 lines
5.4 KiB
TypeScript

'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>
)
}