mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-13 09:48:27 +03:00
feat: new search ui
This commit is contained in:
@@ -6,9 +6,9 @@ import type {
|
||||
ImportDocument,
|
||||
ImportTypes,
|
||||
integer,
|
||||
ProfileRatingAlgorithms,
|
||||
ScoreDocument,
|
||||
SongDocument,
|
||||
ProfileRatingLookup,
|
||||
UserGameStats,
|
||||
} from "tachi-common";
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface UGPTStats<GPT extends GPTString = GPTString> {
|
||||
firstScore: ScoreDocument;
|
||||
mostRecentScore: ScoreDocument;
|
||||
totalScores: integer;
|
||||
rankingData: Record<ProfileRatingLookup[GPT], { ranking: integer; outOf: integer }>;
|
||||
rankingData: Record<ProfileRatingAlgorithms[GPT], { ranking: integer; outOf: integer }>;
|
||||
}
|
||||
|
||||
export interface ChartQueryReturns {
|
||||
|
||||
@@ -191,3 +191,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.topbar {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { NumericSOV, StrSOV } from "util/sorts";
|
||||
import { ONE_MINUTE } from "util/constants/time";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import DebounceSearch from "components/util/DebounceSearch";
|
||||
import Divider from "components/util/Divider";
|
||||
import Loading from "components/util/Loading";
|
||||
import SelectButton from "components/util/SelectButton";
|
||||
import { useTachiSearch } from "components/util/search/useTachiSearch";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Badge, Col, Form, Row } from "react-bootstrap";
|
||||
import {
|
||||
ChartDocument,
|
||||
FormatGame,
|
||||
GPTString,
|
||||
SongDocument,
|
||||
SplitGPT,
|
||||
UserDocument,
|
||||
integer,
|
||||
} from "tachi-common";
|
||||
import TachiTable from "components/tables/components/TachiTable";
|
||||
import { CascadingRatingValue } from "components/tables/headers/ChartHeader";
|
||||
import DifficultyCell from "components/tables/cells/DifficultyCell";
|
||||
import TitleCell from "components/tables/cells/TitleCell";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import Card from "components/layout/page/Card";
|
||||
import ProfilePicture from "components/user/ProfilePicture";
|
||||
import Muted from "components/util/Muted";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function SearchPage() {
|
||||
useSetSubheader("Search");
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [hasPlayedGame, setHasPlayedGame] = useState(true);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<DebounceSearch
|
||||
className="form-control-lg"
|
||||
setSearch={setSearch}
|
||||
placeholder="Search songs, users..."
|
||||
/>
|
||||
{user && (
|
||||
<div className="w-100 mt-4 ml-1">
|
||||
<Form.Check
|
||||
checked={hasPlayedGame}
|
||||
onChange={(e) => setHasPlayedGame(e.target.checked)}
|
||||
label="Hide games you haven't played?"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Divider />
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<SearchResults search={search} hasPlayedGame={hasPlayedGame} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResults({ search, hasPlayedGame }: { search: string; hasPlayedGame: boolean }) {
|
||||
const { data, error } = useTachiSearch(search, hasPlayedGame);
|
||||
const [mode, setMode] = useState<"users" | GPTString | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const thingsWithCharts = Object.entries(data.charts)
|
||||
.sort(StrSOV((x) => x[0]))
|
||||
.filter((k) => k[1].length > 0);
|
||||
|
||||
if (thingsWithCharts.length > 0) {
|
||||
setMode(thingsWithCharts[0][0] as GPTString);
|
||||
} else if (data.users.length > 0) {
|
||||
setMode("users");
|
||||
} else {
|
||||
setMode(null);
|
||||
}
|
||||
} else {
|
||||
setMode(null);
|
||||
}
|
||||
}, [data, hasPlayedGame]);
|
||||
|
||||
if (search === "") {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const hasCharts = Object.values(data.charts).some((k) => k.length > 0);
|
||||
const hasUsers = data.users.length > 0;
|
||||
if (!hasCharts && !hasUsers) {
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12}>Found nothing. Sorry!</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col
|
||||
lg={3}
|
||||
xs={12}
|
||||
className="d-flex"
|
||||
style={{
|
||||
flexWrap: "wrap",
|
||||
flexDirection: "column",
|
||||
gap: "2px",
|
||||
}}
|
||||
>
|
||||
{Object.entries(data.charts)
|
||||
.sort(StrSOV((x) => x[0]))
|
||||
.map(([g, charts]) => {
|
||||
if (charts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gpt = g as GPTString;
|
||||
|
||||
const [game, playtype] = SplitGPT(gpt);
|
||||
|
||||
return (
|
||||
<SelectButton id={gpt} value={mode} setValue={setMode}>
|
||||
{FormatGame(game, playtype)}
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{charts.length}
|
||||
</Badge>
|
||||
</SelectButton>
|
||||
);
|
||||
})}
|
||||
|
||||
{data.users.length > 0 && (
|
||||
<SelectButton
|
||||
id="users"
|
||||
disabled={data.users.length === 0}
|
||||
value={mode}
|
||||
setValue={setMode}
|
||||
>
|
||||
Users
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{data.users.length}
|
||||
</Badge>
|
||||
</SelectButton>
|
||||
)}
|
||||
|
||||
<div className="d-block d-lg-none">
|
||||
<Divider />
|
||||
</div>
|
||||
</Col>
|
||||
{mode !== null && (
|
||||
<Col lg={9} xs={12}>
|
||||
{mode === "users" ? (
|
||||
<UsersView users={data.users} />
|
||||
) : (
|
||||
<ChartView charts={data.charts[mode]!} gpt={mode} />
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartView({
|
||||
charts,
|
||||
gpt,
|
||||
}: {
|
||||
charts: Array<{
|
||||
song: SongDocument;
|
||||
chart: ChartDocument;
|
||||
playcount: integer;
|
||||
}>;
|
||||
gpt: GPTString;
|
||||
}) {
|
||||
const [game, playtype] = SplitGPT(gpt);
|
||||
|
||||
return (
|
||||
<TachiTable
|
||||
entryName="Charts"
|
||||
dataset={charts}
|
||||
headers={[
|
||||
["Chart", "Chart", (a, b) => CascadingRatingValue(game, a.chart, b.chart)],
|
||||
["Song Title", "Song", StrSOV((x) => x.song.title)],
|
||||
["Playcount", "Playcount", NumericSOV((x) => x.playcount)],
|
||||
]}
|
||||
searchFunctions={{
|
||||
title: (x) => x.song.title,
|
||||
artist: (x) => x.song.artist,
|
||||
playcount: (x) => x.playcount,
|
||||
difficulty: (x) => x.chart.difficulty,
|
||||
level: (x) => x.chart.levelNum,
|
||||
}}
|
||||
defaultSortMode="Playcount"
|
||||
defaultReverseSort
|
||||
rowFunction={(d) => (
|
||||
<tr>
|
||||
<DifficultyCell game={game} chart={d.chart} />
|
||||
<TitleCell chart={d.chart} game={game} song={d.song} />
|
||||
<td>{d.playcount}</td>
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersView({ users }: { users: Array<UserDocument> }) {
|
||||
return (
|
||||
<Row>
|
||||
<div
|
||||
className="w-100 d-flex"
|
||||
style={{
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
></div>
|
||||
{users.map((user) => (
|
||||
<Col xs={12} lg={6}>
|
||||
<Card className="mb-4">
|
||||
<div className="d-flex h-100">
|
||||
<div>
|
||||
<ProfilePicture user={user} />
|
||||
</div>
|
||||
<div
|
||||
className="ml-4 d-flex w-100 h-100"
|
||||
style={{
|
||||
flexWrap: "wrap",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h4>
|
||||
<Link className="gentle-link" to={`/u/${user.username}`}>
|
||||
{user.username}
|
||||
</Link>
|
||||
</h4>
|
||||
</div>
|
||||
<div>
|
||||
<Muted>{user.status ?? "I haven't set my status."}</Muted>
|
||||
</div>
|
||||
{Date.now() - user.lastSeen < ONE_MINUTE * 5 && (
|
||||
<div className="mt-2">
|
||||
<Badge variant="success">ONLINE</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import React, { useContext, useEffect, useState } from "react";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { BackgroundContext } from "context/BackgroundContext";
|
||||
import NotificationsPage from "app/pages/dashboard/users/NotificationsPage";
|
||||
import SearchPage from "app/pages/dashboard/search/SearchPage";
|
||||
import { DashboardPage } from "../pages/dashboard/DashboardPage";
|
||||
import CreditsPage from "../pages/dashboard/misc/CreditsPage";
|
||||
import GameRoutes from "./GameRoutes";
|
||||
@@ -74,6 +75,10 @@ export default function DashboardRoutes() {
|
||||
<DashboardPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/search">
|
||||
<SearchPage />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/privacy">
|
||||
<PrivacyPolicyPage />
|
||||
</Route>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import { APIFetchV1, ToCDNURL } from "util/api";
|
||||
import Divider from "components/util/Divider";
|
||||
import Icon from "components/util/Icon";
|
||||
import Loading from "components/util/Loading";
|
||||
import useComponentVisible from "components/util/useComponentVisible";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { AllLUGPTStatsContext } from "context/AllLUGPTStatsContext";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import toast from "react-hot-toast";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FormatGame, GetGameConfig, UserGameStats } from "tachi-common";
|
||||
import { SetState } from "types/react";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import { Button } from "react-bootstrap";
|
||||
import Icon from "components/util/Icon";
|
||||
import toast from "react-hot-toast";
|
||||
import AllGames from "./AllGames";
|
||||
import ImportScoresLink from "./ImportScoresLink";
|
||||
import MenuDropdown from "./MenuDropdown";
|
||||
import MenuLink from "./MenuLink";
|
||||
import SearchBar from "./SearchBar";
|
||||
import UtilsDropdown from "./UtilsDropdown";
|
||||
|
||||
export function HeaderMenu({
|
||||
@@ -119,13 +118,6 @@ export function HeaderMenu({
|
||||
</div>
|
||||
</div>
|
||||
<ul className="menu-nav">
|
||||
<li className="menu-item d-block d-lg-none">
|
||||
<div className="d-flex w-100 mb-8 px-4">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<Divider />
|
||||
</li>
|
||||
|
||||
{user && ugs && ugs.length !== 0 && (
|
||||
<MenuDropdown name="Your Profiles">{userProfileLinks}</MenuDropdown>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useContext } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { SetState } from "types/react";
|
||||
import { UserNotificationButton } from "./UserNotificationButton";
|
||||
import { SearchButton } from "./SearchButton";
|
||||
|
||||
export function HeaderMobile({
|
||||
mobileShow,
|
||||
@@ -28,6 +29,8 @@ export function HeaderMobile({
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center">
|
||||
<SearchButton />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<UserNotificationButton user={user} />
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import Icon from "components/util/Icon";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import { NotificationsContext } from "context/NotificationsContext";
|
||||
import React, { useContext, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { NotificationDocument, UserDocument } from "tachi-common";
|
||||
|
||||
export function SearchButton() {
|
||||
return (
|
||||
<div className="topbar-item">
|
||||
<Link to="/search">
|
||||
<div className="btn btn-icon btn-hover-transparent-white btn-dropdown btn-lg mr-1">
|
||||
<Icon type="search" colour="muted" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,15 @@ import { UserProfileDropdown } from "components/layout/header/UserProfileDropdow
|
||||
import LinkButton from "components/util/LinkButton";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext } from "react";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { UserNotificationButton } from "./UserNotificationButton";
|
||||
import { SearchButton } from "./SearchButton";
|
||||
|
||||
export function Topbar() {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<div className="topbar" style={{ minWidth: "35%" }}>
|
||||
<SearchBar />
|
||||
<SearchButton />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { APIFetchV1, UnsuccessfulAPIFetchResponse } from "util/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ChartDocument,
|
||||
FolderDocument,
|
||||
GPTString,
|
||||
SongDocument,
|
||||
UserDocument,
|
||||
integer,
|
||||
} from "tachi-common";
|
||||
|
||||
type SearchResults = {
|
||||
users: Array<UserDocument>;
|
||||
charts: {
|
||||
[GPT in GPTString]?: Array<{
|
||||
song: SongDocument;
|
||||
chart: ChartDocument;
|
||||
playcount: integer;
|
||||
}>;
|
||||
};
|
||||
folders: Array<FolderDocument>;
|
||||
};
|
||||
|
||||
type ChartHashSearchReturns = {
|
||||
charts: {
|
||||
[GPT in GPTString]?: Array<{
|
||||
song: SongDocument;
|
||||
chart: ChartDocument;
|
||||
playcount: integer;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export function useTachiSearch(
|
||||
search: string,
|
||||
hasPlayedGame = false
|
||||
): {
|
||||
data: SearchResults | null;
|
||||
error: UnsuccessfulAPIFetchResponse | null;
|
||||
} {
|
||||
const [data, setData] = useState<SearchResults | null>(null);
|
||||
const [error, setError] = useState<UnsuccessfulAPIFetchResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (search === "") {
|
||||
setData({
|
||||
charts: {},
|
||||
folders: [],
|
||||
users: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// loadin...
|
||||
setData(null);
|
||||
|
||||
const searches = [];
|
||||
|
||||
searches.push(
|
||||
APIFetchV1<SearchResults>(
|
||||
`/search?search=${encodeURIComponent(search)}${
|
||||
hasPlayedGame ? "&hasPlayedGame=true" : ""
|
||||
}`
|
||||
)
|
||||
);
|
||||
|
||||
searches.push(
|
||||
APIFetchV1<ChartHashSearchReturns>(
|
||||
`/search/chart-hash?search=${encodeURIComponent(search)}`
|
||||
)
|
||||
);
|
||||
|
||||
Promise.all(searches)
|
||||
.then((results) => {
|
||||
const setValue: SearchResults = {
|
||||
users: [],
|
||||
charts: {},
|
||||
folders: [],
|
||||
};
|
||||
|
||||
for (const result of results) {
|
||||
if (result.success === false) {
|
||||
console.error(result);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [g, charts] of Object.entries(result.body.charts)) {
|
||||
const gpt = g as GPTString;
|
||||
|
||||
if (setValue.charts[gpt]) {
|
||||
setValue.charts[gpt]!.push(...charts);
|
||||
} else {
|
||||
setValue.charts[gpt] = charts;
|
||||
}
|
||||
}
|
||||
|
||||
if ("users" in result.body) {
|
||||
setValue.users.push(...(result.body.users as Array<UserDocument>));
|
||||
}
|
||||
}
|
||||
|
||||
setData(setValue);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setData(null);
|
||||
setError({
|
||||
statusCode: 500,
|
||||
description: "Failed to reach the API.",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
}, [search, hasPlayedGame]);
|
||||
|
||||
return { data, error };
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getUserActions } from "app/actions/user";
|
||||
import React, { createContext, useState } from "react";
|
||||
import { UserDocument } from "tachi-common";
|
||||
import { JustChildren, SetState } from "types/react";
|
||||
|
||||
@@ -62,6 +62,10 @@ export function GetGPTString(game: Game, playtype: Playtype): GPTString {
|
||||
return `${game}:${playtype}` as GPTString;
|
||||
}
|
||||
|
||||
export function SplitGPT(gpt: GPTString) {
|
||||
return gpt.split(":") as [Game, Playtype];
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on every declared playtype for every declared game, they all need a GPT
|
||||
* config. This controls almost everything about each GPT.
|
||||
|
||||
@@ -54,7 +54,7 @@ export function FormatDifficulty(chart: ChartDocument, game: Game): string {
|
||||
// actually possible.
|
||||
// todo: come up with something better.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||
const shortDiff = gptConfig.difficulties.difficultyShorthand[ch.difficulty];
|
||||
const shortDiff = gptConfig.difficulties.shorthand[ch.difficulty];
|
||||
|
||||
// gitadora should always use short diffs. they just look better.
|
||||
return `${shortDiff} ${chart.level}`;
|
||||
|
||||
+121
-49
@@ -2,6 +2,8 @@ import deepmerge from "deepmerge";
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { CreateSongMap, GetGPTString, SplitGPT } from "tachi-common";
|
||||
import { GetSongForIDGuaranteed } from "utils/db";
|
||||
import { EscapeStringRegexp } from "utils/misc";
|
||||
import { GetOnlineCutoff } from "utils/user";
|
||||
import type { FilterQuery } from "mongodb";
|
||||
@@ -15,6 +17,8 @@ import type {
|
||||
UserDocument,
|
||||
SessionDocument,
|
||||
SongDocument,
|
||||
GPTString,
|
||||
GPTStrings,
|
||||
} from "tachi-common";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
@@ -94,11 +98,71 @@ export async function SearchSpecificGameSongsAndCharts(
|
||||
chartQuery.playtype = playtype;
|
||||
}
|
||||
|
||||
const charts = (await db.anyCharts[game].find(chartQuery)) as Array<ChartDocument>;
|
||||
const charts = (await db.anyCharts[game].find(chartQuery, { limit })) as Array<ChartDocument>;
|
||||
|
||||
return { songs, charts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search this Game/GPTs songs and charts, but globally.
|
||||
*/
|
||||
export async function SearchGlobalGameSongsAndCharts(
|
||||
game: Game,
|
||||
search: string,
|
||||
playtype?: Playtype,
|
||||
limit = 100
|
||||
): Promise<Array<{ song: SongDocument; chart: ChartDocument; playcount: integer }>> {
|
||||
const songs = await SearchSpecificGameSongs(game, search, limit);
|
||||
|
||||
const chartQuery: FilterQuery<ChartDocument> = {
|
||||
songID: { $in: songs.map((e) => e.id) },
|
||||
};
|
||||
|
||||
if (playtype) {
|
||||
chartQuery.playtype = playtype;
|
||||
}
|
||||
|
||||
const charts = (await db.anyCharts[game].find(chartQuery, { limit })) as unknown as Array<
|
||||
ChartDocument & { __playcount: integer }
|
||||
>;
|
||||
|
||||
const playcounts: Array<{ _id: string; playcount: integer }> = await db.scores.aggregate([
|
||||
{
|
||||
$match: {
|
||||
chartID: { $in: charts.map((e) => e.chartID) },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$chartID",
|
||||
playcount: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const playcountLookup = Object.fromEntries(playcounts.map((e) => [e._id, e.playcount]));
|
||||
const songMap = CreateSongMap(songs);
|
||||
|
||||
const output = [];
|
||||
|
||||
for (const chart of charts) {
|
||||
const song = songMap.get(chart.songID);
|
||||
|
||||
if (!song) {
|
||||
logger.warn(`Failed to find parent song for ${chart.songID} (${game})? Skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push({
|
||||
song,
|
||||
chart,
|
||||
playcount: playcountLookup[chart.chartID] ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function SearchSessions(
|
||||
search: string,
|
||||
game?: Game,
|
||||
@@ -184,68 +248,76 @@ export async function SearchGamesSongs(search: string, games: Array<Game>) {
|
||||
return res.flat(1).sort((a, b) => b.__textScore - a.__textScore);
|
||||
}
|
||||
|
||||
export async function SearchGamesSongsCharts(search: string, games: Array<Game>) {
|
||||
export async function SearchGamesSongsCharts(search: string, gpts: Array<GPTString>) {
|
||||
const promises = [];
|
||||
|
||||
for (const game of games) {
|
||||
const results: Partial<
|
||||
Record<GPTString, Array<{ song: SongDocument; chart: ChartDocument; playcount: integer }>>
|
||||
> = {};
|
||||
|
||||
for (const gpt of gpts) {
|
||||
const [game, playtype] = SplitGPT(gpt);
|
||||
|
||||
promises.push(
|
||||
SearchSpecificGameSongsAndCharts(game, search).then((e) => ({
|
||||
game,
|
||||
songs: e.songs,
|
||||
charts: e.charts,
|
||||
}))
|
||||
SearchGlobalGameSongsAndCharts(game, search, playtype).then((res) => {
|
||||
results[gpt] = res;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
await Promise.all(promises);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function SearchForChartHash(search: string) {
|
||||
const results = (await Promise.all([
|
||||
db.charts.bms.findOne({ $or: [{ "data.hashMD5": search }, { "data.hashSHA256": search }] }),
|
||||
db.charts.pms.findOne({ $or: [{ "data.hashMD5": search }, { "data.hashSHA256": search }] }),
|
||||
db.charts.usc.findOne({ "data.hashSHA1": search }),
|
||||
])) as [ChartDocument | null, ChartDocument | null, ChartDocument | null];
|
||||
const results = await Promise.all([
|
||||
db.charts.bms.find({ $or: [{ "data.hashMD5": search }, { "data.hashSHA256": search }] }),
|
||||
db.charts.pms.find({ $or: [{ "data.hashMD5": search }, { "data.hashSHA256": search }] }),
|
||||
db.charts.usc.find({ "data.hashSHA1": search }),
|
||||
db.charts.itg.find({ "data.hashGSv3": search }),
|
||||
]);
|
||||
|
||||
const [bmsChart, pmsChart, uscChart] = results;
|
||||
const [bmsCharts, pmsCharts, uscCharts, itgCharts] = results;
|
||||
|
||||
const songs = [];
|
||||
const charts = results.filter((e) => e !== null) as Array<ChartDocument>;
|
||||
const output: Record<
|
||||
GPTStrings["bms" | "itg" | "pms" | "usc"],
|
||||
Array<{
|
||||
song: SongDocument;
|
||||
chart: ChartDocument;
|
||||
playcount: null;
|
||||
}>
|
||||
> = {
|
||||
"bms:7K": [],
|
||||
"bms:14K": [],
|
||||
"pms:Controller": [],
|
||||
"pms:Keyboard": [],
|
||||
"usc:Controller": [],
|
||||
"usc:Keyboard": [],
|
||||
"itg:Stamina": [],
|
||||
};
|
||||
|
||||
if (bmsChart) {
|
||||
songs.push(
|
||||
AddGamePropToSong(
|
||||
(await db.songs.bms.findOne({
|
||||
id: bmsChart.songID,
|
||||
}))!,
|
||||
"bms"
|
||||
)
|
||||
);
|
||||
const zip = [
|
||||
["bms", bmsCharts],
|
||||
["pms", pmsCharts],
|
||||
["itg", itgCharts],
|
||||
["usc", uscCharts],
|
||||
] as const;
|
||||
|
||||
for (const [game, charts] of zip) {
|
||||
for (const chart of charts as Array<ChartDocument>) {
|
||||
const song = await GetSongForIDGuaranteed(game, chart.songID);
|
||||
|
||||
// @ts-expect-error ts doesn't like this hack but it'll work.
|
||||
output[`${game}:${chart.playtype}`]!.push({
|
||||
song,
|
||||
chart,
|
||||
playcount: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (pmsChart) {
|
||||
songs.push(
|
||||
AddGamePropToSong(
|
||||
(await db.songs.pms.findOne({
|
||||
id: pmsChart.songID,
|
||||
}))!,
|
||||
"pms"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (uscChart) {
|
||||
songs.push(
|
||||
AddGamePropToSong(
|
||||
(await db.songs.usc.findOne({
|
||||
id: uscChart.songID,
|
||||
}))!,
|
||||
"usc"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return { songs, charts };
|
||||
return output;
|
||||
}
|
||||
|
||||
export function SearchFolders(
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
SearchUsersRegExp,
|
||||
} from "lib/search/search";
|
||||
import { TachiConfig } from "lib/setup/config";
|
||||
import { Game, GetGPTString, GetGameConfig } from "tachi-common";
|
||||
import { IsString } from "utils/misc";
|
||||
import { GetAllUserRivals, GetUserPlayedGPTs } from "utils/user";
|
||||
import type { FilterQuery } from "mongodb";
|
||||
import type { FolderDocument, Game, SongDocument, UserDocument, integer } from "tachi-common";
|
||||
import type { FolderDocument, GPTString, SongDocument, UserDocument, integer } from "tachi-common";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
@@ -32,7 +33,10 @@ router.get("/", async (req, res) => {
|
||||
const userID = req[SYMBOL_TACHI_API_AUTH].userID;
|
||||
|
||||
let filter: FilterQuery<FolderDocument & SongDocument & UserDocument> = {};
|
||||
let relevantGames: Array<Game> = TachiConfig.GAMES;
|
||||
|
||||
let relevantGPTs: Array<GPTString> = TachiConfig.GAMES.flatMap((g) =>
|
||||
GetGameConfig(g).playtypes.map((pt) => GetGPTString(g, pt))
|
||||
);
|
||||
|
||||
if (userID !== null) {
|
||||
// if the requesting user exists, and they've set this param,
|
||||
@@ -47,21 +51,16 @@ router.get("/", async (req, res) => {
|
||||
playtype: { $in: gpts.map((e) => e.playtype) },
|
||||
};
|
||||
|
||||
relevantGames = gpts.map((e) => e.game);
|
||||
relevantGPTs = gpts.map((e) => GetGPTString(e.game, e.playtype));
|
||||
}
|
||||
}
|
||||
|
||||
const [users, songChartData, folders] = await Promise.all([
|
||||
const [users, charts, folders] = await Promise.all([
|
||||
SearchUsersRegExp(req.query.search),
|
||||
SearchGamesSongsCharts(req.query.search, relevantGames),
|
||||
SearchGamesSongsCharts(req.query.search, relevantGPTs),
|
||||
SearchFolders(req.query.search, filter),
|
||||
]);
|
||||
|
||||
const songs = songChartData.flatMap((e) => e.songs.map((song) => ({ game: e.game, ...song })));
|
||||
const charts = songChartData.flatMap((e) =>
|
||||
e.charts.map((chart) => ({ game: e.game, ...chart }))
|
||||
);
|
||||
|
||||
// @ts-expect-error Handled below -- the field is added by the below for loop.
|
||||
const usersWithRivalTag: Array<UserDocument & { __isRival: boolean }> = users;
|
||||
|
||||
@@ -80,7 +79,6 @@ router.get("/", async (req, res) => {
|
||||
description: `Searched everything.`,
|
||||
body: {
|
||||
users,
|
||||
songs,
|
||||
charts,
|
||||
folders,
|
||||
},
|
||||
@@ -90,9 +88,9 @@ router.get("/", async (req, res) => {
|
||||
/**
|
||||
* Search checksums for charts, instead of matching on song title.
|
||||
*
|
||||
* @param search - The hash to search on
|
||||
* @param search - The hash to search on.
|
||||
*
|
||||
* @note This matches MD5 and SHA256 for BMS/PMS, and SHA1 for USC.
|
||||
* @note This matches MD5 and SHA256 for BMS/PMS, GSv3 for ITG and SHA1 for USC.
|
||||
*/
|
||||
router.get("/chart-hash", async (req, res) => {
|
||||
if (!IsString(req.query.search)) {
|
||||
@@ -102,13 +100,12 @@ router.get("/chart-hash", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { songs, charts } = await SearchForChartHash(req.query.search);
|
||||
const charts = await SearchForChartHash(req.query.search);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Searched for chart hash ${req.query.search}.`,
|
||||
body: {
|
||||
songs,
|
||||
charts,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user