chore: standardize security and quality foundations
This commit is contained in:
+135
-102
@@ -1,146 +1,179 @@
|
||||
'use client'
|
||||
"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'
|
||||
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
|
||||
}
|
||||
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')
|
||||
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)
|
||||
}, [])
|
||||
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
|
||||
const term = query.trim();
|
||||
requestVersion.current += 1;
|
||||
const version = requestVersion.current;
|
||||
if (term.length < 2 || !canSearch) {
|
||||
setResults([])
|
||||
setSearching(false)
|
||||
setMessage(null)
|
||||
return
|
||||
setResults([]);
|
||||
setSearching(false);
|
||||
setMessage(null);
|
||||
return;
|
||||
}
|
||||
setSearching(true)
|
||||
setMessage(null)
|
||||
const controller = new AbortController()
|
||||
setSearching(true);
|
||||
setMessage(null);
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ query: term })
|
||||
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
|
||||
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)
|
||||
.filter(
|
||||
(item: Record<string, unknown>) =>
|
||||
typeof item.type === "string" && ["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.')
|
||||
.map(
|
||||
(item: Record<string, unknown>): SearchResult => ({
|
||||
title: String(item?.title || "Untitled"),
|
||||
year: typeof item?.year === "number" ? item.year : null,
|
||||
type: item.type === "tv" ? "tv" : "movie",
|
||||
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.')
|
||||
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)
|
||||
if (version === requestVersion.current) setSearching(false);
|
||||
}
|
||||
}, 280)
|
||||
}, 280);
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [query, canSearch])
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query, canSearch]);
|
||||
|
||||
if (!ready || !user || !canSearch) return null
|
||||
if (!ready || !user || !canSearch) return null;
|
||||
|
||||
const openResult = (result: SearchResult) => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
if (result.requestId && canOpenRequests) {
|
||||
router.push(`/requests/${result.requestId}`)
|
||||
return
|
||||
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
|
||||
const params = new URLSearchParams({ type: result.type, query: result.title });
|
||||
router.push(`/new-requests?${params.toString()}`);
|
||||
return;
|
||||
}
|
||||
router.push('/portal/issues')
|
||||
}
|
||||
router.push("/portal/issues");
|
||||
};
|
||||
|
||||
const showResults = open && query.trim().length >= 2
|
||||
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>
|
||||
<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
|
||||
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