feat: new search ui

This commit is contained in:
zkldi
2023-01-24 21:29:02 +00:00
parent aa1b73b72c
commit a06286eec7
14 changed files with 553 additions and 82 deletions
+2 -2
View File
@@ -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 {
+3
View File
@@ -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
View File
@@ -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";
+4
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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,
},
});