180 lines
6.1 KiB
TypeScript
180 lines
6.1 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: Record<string, unknown>) =>
|
|
typeof item.type === "string" && ["movie", "tv"].includes(item.type) && Number(item.tmdbId) > 0,
|
|
)
|
|
.slice(0, 7)
|
|
.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.");
|
|
} 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>
|
|
);
|
|
}
|