Files
zkldi_Tachi/client/src/components/layout/header/SearchBar.tsx
T

303 lines
7.8 KiB
TypeScript

import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import { UserContext } from "context/UserContext";
import { TachiConfig } from "lib/config";
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Game, GetGameConfig, integer, PublicUserDocument, SongDocument } from "tachi-common";
import { GamePT, JustChildren } from "types/react";
import { Playtype } from "types/tachi";
import { APIFetchV1 } from "util/api";
import { ConditionalLeadingSpace, PREVENT_DEFAULT } from "util/misc";
import QuickTooltip from "../misc/QuickTooltip";
interface SearchReturns {
users?: PublicUserDocument[];
songs: (SongDocument & { __textScore: number; game: Game })[];
}
function SearchResult({
children,
link,
tabIndex,
}: JustChildren & { link: string; tabIndex?: integer }) {
return (
<Link
className="d-flex align-items-center flex-grow-1 mb-2 search-result"
tabIndex={tabIndex}
to={link}
>
<div className="d-flex flex-column ml-3 mt-2 mb-2">{children}</div>
</Link>
);
}
function GPTSearchResult({
user,
game,
playtype,
tabIndex,
}: {
user: PublicUserDocument;
tabIndex: integer;
} & GamePT) {
const currentLoc = window.location.href.split(`games/${game}/${playtype}`)[1];
return (
<div className="d-flex flex-column ml-3 mt-2 mb-2">
<strong>
<Link to={`/dashboard/users/${user.username}`}>{user.username}</Link>
<Link
className="float-right gentle-link mr-4"
tabIndex={tabIndex}
to={`/dashboard/users/${user.username}/games/${game}/${playtype}${currentLoc}`}
style={{
fontWeight: "lighter",
}}
>
<QuickTooltip tooltipContent={`Go to this page, but for ${user.username}.`}>
<span>
<Icon type="exchange-alt" />
</span>
</QuickTooltip>
</Link>
</strong>
<span className="font-size-sm font-weight-bold text-muted">{user.status}</span>
</div>
);
}
function SearchResults({ results }: { results: SearchReturns }) {
if (!results.users?.length && !results.songs.length) {
return (
<div className="font-size-sm font-weight-bolder text-uppercase mb-2 text-center">
Found nothing :(
</div>
);
}
// useParams doesn't do what i think it does, so...
// here's a hack.
const params = useLocation<{ game?: Game; playtype?: Playtype }>();
const { game, playtype, user } = useMemo(() => {
const regexp = `/games/(${TachiConfig.games.join("|")})/[^/]*/?`;
if (params.pathname.match(new RegExp(regexp, "u"))) {
const hack = params.pathname.split("/games/")[1].split("/");
return {
game: hack[0] as Game,
playtype: hack[1] as Playtype,
user: params.pathname.match(/\/dashboard\/users\/(.*?)\//u)?.[1],
};
}
return { game: null, playtype: null };
}, [params.pathname]);
return (
<div className="quick-search-result">
{results?.users?.length ? (
<>
<div className="font-size-sm text-primary font-weight-bolder text-uppercase mb-2">
Users
</div>
<div>
{results.users.map(u =>
game && playtype && user ? (
<GPTSearchResult
key={u.id}
game={game}
playtype={playtype}
tabIndex={0}
user={u}
/>
) : (
<SearchResult
key={u.id}
link={`/dashboard/users/${u.username}`}
tabIndex={0}
>
<strong>{u.username}</strong>
<span className="font-size-sm font-weight-bold text-muted">
{u.status}
</span>
</SearchResult>
)
)}
</div>
<Divider className="mt-2 mb-4" />
</>
) : (
<></>
)}
{results?.songs.length ? (
<>
<div className="font-size-sm text-primary font-weight-bolder text-uppercase mb-2">
Songs
</div>
<div className="mb-4">
{results.songs.map(s => (
<SearchResult
key={s.id + s.game}
link={`/dashboard/games/${s.game}/${playtype ??
GetGameConfig(s.game).defaultPlaytype}/songs/${s.id}`}
tabIndex={0}
>
<strong>{FormatSongTitle(s.game, s)}</strong>
<span className="font-size-sm font-weight-bold text-muted">
{GetGameConfig(s.game).name}
</span>
</SearchResult>
))}
</div>
</>
) : (
<></>
)}
</div>
);
}
function FormatSongTitle(game: Game, song: SongDocument) {
if (game === "usc") {
const s = song as SongDocument<"usc">;
return `${song.title} (${s.data.effector})`;
} else if (game === "bms" || game === "pms") {
const s = song as SongDocument<"bms" | "pms">;
return `${song.title}${ConditionalLeadingSpace(s.data.subtitle)}${ConditionalLeadingSpace(
s.data.tableString
)}`;
}
return song.title;
}
export default function SearchBar() {
const [show, setShow] = useState(false);
const [search, setSearch] = useState("");
const [results, setResults] = useState<SearchReturns | null>(null);
const [lastTimeout, setLastTimeout] = useState<number | null>(null);
const ref = useRef(null);
const inputRef = useRef(null);
// onclick outside
useEffect(() => {
function clickOutside(event: MouseEvent) {
// @ts-expect-error lazy
if (ref.current && !ref.current.contains(event.target)) {
setShow(false);
}
}
document.addEventListener("mousedown", clickOutside);
return () => {
document.removeEventListener("mousedown", clickOutside);
};
}, [ref]);
useEffect(() => {
setShow(search !== "");
}, [search]);
// debouncer result-getter
function UpdateSearch(event: React.ChangeEvent<HTMLInputElement>) {
setSearch(event.target.value);
if (lastTimeout !== null) {
clearTimeout(lastTimeout);
}
const closureSearch = event.target.value;
// compatibility note - we use window here to specify to typescript
// that this is NOT the node version of setTimeout() (which does not return a number).
const handle = window.setTimeout(() => {
const searches = [];
searches.push(
APIFetchV1<SearchReturns>(`/search?search=${encodeURIComponent(closureSearch)}`)
);
if (TachiConfig.type !== "ktchi") {
searches.push(
APIFetchV1<SearchReturns>(
`/search/chart-hash?search=${encodeURIComponent(closureSearch)}`
)
);
}
Promise.all(searches).then(results => {
const setValue: SearchReturns = {
users: [],
songs: [],
};
for (const result of results) {
if (result.success === false) {
console.error(result);
return;
}
setValue.songs.push(...result.body.songs);
setValue.users!.push(...(result.body.users ?? []));
}
setResults(setValue);
});
}, 250);
setLastTimeout(handle);
}
return (
<div ref={ref} className="align-self-center" style={{ flexGrow: 1 }}>
<div className="topbar-item mr-2">
<form className="quick-search-form" onSubmit={PREVENT_DEFAULT}>
<div className="input-group">
<input
className="form-control"
type="text"
ref={inputRef}
value={search}
onChange={UpdateSearch}
onClick={() => {
if (search !== "") {
setShow(true);
}
}}
placeholder="Search users, songs, charts..."
/>
</div>
</form>
</div>
<div style={{ position: "relative", top: "20px" }}>
<div
className={`dropdown-menu p-0 m-0 dropdown-menu-right dropdown-menu-anim-up dropdown-menu-lg dropdown-menu dropdown-menu-right ${
show ? "show" : "hide"
}`}
style={{
position: "absolute",
borderRadius: "0 0 5px 5px",
}}
>
<div
id="kt_quick_search_dropdown"
className="quick-search quick-search-dropdown quick-search-has-result"
>
<div
style={{ maxHeight: "325px", overflowY: "scroll" }}
className="quick-search-wrapper"
>
{results ? <SearchResults results={results} /> : <Loading />}
</div>
</div>
</div>
</div>
</div>
);
}