diff --git a/bot/src/utils/returnTypes.ts b/bot/src/utils/returnTypes.ts index 97f47868a..36e238fd4 100644 --- a/bot/src/utils/returnTypes.ts +++ b/bot/src/utils/returnTypes.ts @@ -6,9 +6,9 @@ import type { ImportDocument, ImportTypes, integer, + ProfileRatingAlgorithms, ScoreDocument, SongDocument, - ProfileRatingLookup, UserGameStats, } from "tachi-common"; @@ -50,7 +50,7 @@ export interface UGPTStats { firstScore: ScoreDocument; mostRecentScore: ScoreDocument; totalScores: integer; - rankingData: Record; + rankingData: Record; } export interface ChartQueryReturns { diff --git a/client/src/_style/base.scss b/client/src/_style/base.scss index 8b53b6583..ef78ee94c 100644 --- a/client/src/_style/base.scss +++ b/client/src/_style/base.scss @@ -191,3 +191,6 @@ } } +.topbar { + justify-content: end; +} diff --git a/client/src/app/pages/dashboard/search/SearchPage.tsx b/client/src/app/pages/dashboard/search/SearchPage.tsx new file mode 100644 index 000000000..a8978b0f7 --- /dev/null +++ b/client/src/app/pages/dashboard/search/SearchPage.tsx @@ -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 ( + + + + {user && ( +
+ setHasPlayedGame(e.target.checked)} + label="Hide games you haven't played?" + /> +
+ )} + + + + + +
+ ); +} + +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 ; + } + + if (!data) { + return ; + } + + const hasCharts = Object.values(data.charts).some((k) => k.length > 0); + const hasUsers = data.users.length > 0; + if (!hasCharts && !hasUsers) { + return ( + + Found nothing. Sorry! + + ); + } + + return ( + + + {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 ( + + {FormatGame(game, playtype)} + + {charts.length} + + + ); + })} + + {data.users.length > 0 && ( + + Users + + {data.users.length} + + + )} + +
+ +
+ + {mode !== null && ( + + {mode === "users" ? ( + + ) : ( + + )} + + )} +
+ ); +} + +function ChartView({ + charts, + gpt, +}: { + charts: Array<{ + song: SongDocument; + chart: ChartDocument; + playcount: integer; + }>; + gpt: GPTString; +}) { + const [game, playtype] = SplitGPT(gpt); + + return ( + 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) => ( + + + + {d.playcount} + + )} + /> + ); +} + +function UsersView({ users }: { users: Array }) { + return ( + +
+ {users.map((user) => ( + + +
+
+ +
+
+
+

+ + {user.username} + +

+
+
+ {user.status ?? "I haven't set my status."} +
+ {Date.now() - user.lastSeen < ONE_MINUTE * 5 && ( +
+ ONLINE +
+ )} +
+
+
+ + ))} +
+ ); +} diff --git a/client/src/app/routes/DashboardRoutes.tsx b/client/src/app/routes/DashboardRoutes.tsx index 58049a721..b65fc6a59 100644 --- a/client/src/app/routes/DashboardRoutes.tsx +++ b/client/src/app/routes/DashboardRoutes.tsx @@ -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() { + + + + diff --git a/client/src/components/layout/header/HeaderMenu.tsx b/client/src/components/layout/header/HeaderMenu.tsx index 9608035cf..5cc241a6f 100644 --- a/client/src/components/layout/header/HeaderMenu.tsx +++ b/client/src/components/layout/header/HeaderMenu.tsx @@ -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({
    -
  • -
    - -
    - -
  • - {user && ugs && ugs.length !== 0 && ( {userProfileLinks} )} diff --git a/client/src/components/layout/header/HeaderMobile.tsx b/client/src/components/layout/header/HeaderMobile.tsx index 18548ed2e..b4dad5fb2 100644 --- a/client/src/components/layout/header/HeaderMobile.tsx +++ b/client/src/components/layout/header/HeaderMobile.tsx @@ -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({
    + + {user ? ( <> diff --git a/client/src/components/layout/header/SearchButton.tsx b/client/src/components/layout/header/SearchButton.tsx new file mode 100644 index 000000000..81783f5ac --- /dev/null +++ b/client/src/components/layout/header/SearchButton.tsx @@ -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 ( +
    + +
    + +
    + +
    + ); +} diff --git a/client/src/components/layout/header/Topbar.tsx b/client/src/components/layout/header/Topbar.tsx index be3385835..2b0704d07 100644 --- a/client/src/components/layout/header/Topbar.tsx +++ b/client/src/components/layout/header/Topbar.tsx @@ -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 (
    - + {user ? ( <> diff --git a/client/src/components/util/search/useTachiSearch.ts b/client/src/components/util/search/useTachiSearch.ts new file mode 100644 index 000000000..308669064 --- /dev/null +++ b/client/src/components/util/search/useTachiSearch.ts @@ -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; + charts: { + [GPT in GPTString]?: Array<{ + song: SongDocument; + chart: ChartDocument; + playcount: integer; + }>; + }; + folders: Array; +}; + +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(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (search === "") { + setData({ + charts: {}, + folders: [], + users: [], + }); + return; + } + + // loadin... + setData(null); + + const searches = []; + + searches.push( + APIFetchV1( + `/search?search=${encodeURIComponent(search)}${ + hasPlayedGame ? "&hasPlayedGame=true" : "" + }` + ) + ); + + searches.push( + APIFetchV1( + `/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)); + } + } + + 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 }; +} diff --git a/client/src/context/UserContext.tsx b/client/src/context/UserContext.tsx index ff73cd1cd..2ed1be239 100644 --- a/client/src/context/UserContext.tsx +++ b/client/src/context/UserContext.tsx @@ -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"; diff --git a/common/src/config/config.ts b/common/src/config/config.ts index 7eff17ffd..0d84022e3 100644 --- a/common/src/config/config.ts +++ b/common/src/config/config.ts @@ -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. diff --git a/common/src/utils/util.ts b/common/src/utils/util.ts index efacdae1f..e8b7a224c 100644 --- a/common/src/utils/util.ts +++ b/common/src/utils/util.ts @@ -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}`; diff --git a/server/src/lib/search/search.ts b/server/src/lib/search/search.ts index 014253e96..a03ddf7a8 100644 --- a/server/src/lib/search/search.ts +++ b/server/src/lib/search/search.ts @@ -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; + const charts = (await db.anyCharts[game].find(chartQuery, { limit })) as Array; 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> { + const songs = await SearchSpecificGameSongs(game, search, limit); + + const chartQuery: FilterQuery = { + 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) { return res.flat(1).sort((a, b) => b.__textScore - a.__textScore); } -export async function SearchGamesSongsCharts(search: string, games: Array) { +export async function SearchGamesSongsCharts(search: string, gpts: Array) { const promises = []; - for (const game of games) { + const results: Partial< + Record> + > = {}; + + 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; + 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) { + 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( diff --git a/server/src/server/router/api/v1/search/router.ts b/server/src/server/router/api/v1/search/router.ts index 7a25ca370..be5869968 100644 --- a/server/src/server/router/api/v1/search/router.ts +++ b/server/src/server/router/api/v1/search/router.ts @@ -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 = {}; - let relevantGames: Array = TachiConfig.GAMES; + + let relevantGPTs: Array = 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 = 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, }, });