feat: simplify ready requests and add global search
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user