mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-11 16:58:25 +03:00
fix: stupid crlfisms
This commit is contained in:
+4
-4
@@ -1,5 +1,5 @@
|
||||
# Tachi Bot
|
||||
|
||||
This is a discord bot that interacts with an instance of Tachi.
|
||||
|
||||
# Tachi Bot
|
||||
|
||||
This is a discord bot that interacts with an instance of Tachi.
|
||||
|
||||
It's currently not documented, so you'll have to read the source to figure things out. Sorry!
|
||||
+38
-38
@@ -1,39 +1,39 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"baseUrl": "src",
|
||||
"outDir": "js",
|
||||
"lib": [
|
||||
"DOM" // hack workaround. URLSearchParams must be imported, yet
|
||||
// discordJS carelessly uses it without being imported
|
||||
// as if it were a DOM type.
|
||||
// Sad!
|
||||
],
|
||||
"types": [
|
||||
"node",
|
||||
],
|
||||
"paths": {
|
||||
"tachi-common": [
|
||||
"../../common/src"
|
||||
],
|
||||
"tachi-common/*": [
|
||||
"../../common/src/*"
|
||||
]
|
||||
},
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": ".tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
],
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"baseUrl": "src",
|
||||
"outDir": "js",
|
||||
"lib": [
|
||||
"DOM" // hack workaround. URLSearchParams must be imported, yet
|
||||
// discordJS carelessly uses it without being imported
|
||||
// as if it were a DOM type.
|
||||
// Sad!
|
||||
],
|
||||
"types": [
|
||||
"node",
|
||||
],
|
||||
"paths": {
|
||||
"tachi-common": [
|
||||
"../../common/src"
|
||||
],
|
||||
"tachi-common/*": [
|
||||
"../../common/src/*"
|
||||
]
|
||||
},
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": ".tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
],
|
||||
}
|
||||
+46
-46
@@ -1,46 +1,46 @@
|
||||
import { MaterialThemeProvider } from "components/layout/misc/MaterialThemeProvider";
|
||||
import { LoadingScreen } from "components/layout/screens/LoadingScreen";
|
||||
import { AllLUGPTStatsContextProvider } from "context/AllLUGPTStatsContext";
|
||||
import { BannedContextProvider } from "context/BannedContext";
|
||||
import { NotificationsContextProvider } from "context/NotificationsContext";
|
||||
import { SubheaderContextProvider } from "context/SubheaderContext";
|
||||
import { UserContextProvider } from "context/UserContext";
|
||||
import { UserSettingsContextProvider } from "context/UserSettingsContext";
|
||||
import React from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { Routes } from "./routes/AppRoutes";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: false } },
|
||||
});
|
||||
|
||||
export default function App({ basename }: { basename: string }) {
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BannedContextProvider>
|
||||
<UserContextProvider>
|
||||
<NotificationsContextProvider>
|
||||
<UserSettingsContextProvider>
|
||||
<AllLUGPTStatsContextProvider>
|
||||
<LoadingScreen>
|
||||
<BrowserRouter basename={basename}>
|
||||
<MaterialThemeProvider>
|
||||
<Toaster />
|
||||
<SubheaderContextProvider>
|
||||
<Routes />
|
||||
</SubheaderContextProvider>
|
||||
</MaterialThemeProvider>
|
||||
</BrowserRouter>
|
||||
</LoadingScreen>
|
||||
</AllLUGPTStatsContextProvider>
|
||||
</UserSettingsContextProvider>
|
||||
</NotificationsContextProvider>
|
||||
</UserContextProvider>
|
||||
</BannedContextProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
import { MaterialThemeProvider } from "components/layout/misc/MaterialThemeProvider";
|
||||
import { LoadingScreen } from "components/layout/screens/LoadingScreen";
|
||||
import { AllLUGPTStatsContextProvider } from "context/AllLUGPTStatsContext";
|
||||
import { BannedContextProvider } from "context/BannedContext";
|
||||
import { NotificationsContextProvider } from "context/NotificationsContext";
|
||||
import { SubheaderContextProvider } from "context/SubheaderContext";
|
||||
import { UserContextProvider } from "context/UserContext";
|
||||
import { UserSettingsContextProvider } from "context/UserSettingsContext";
|
||||
import React from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { Routes } from "./routes/AppRoutes";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: false } },
|
||||
});
|
||||
|
||||
export default function App({ basename }: { basename: string }) {
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BannedContextProvider>
|
||||
<UserContextProvider>
|
||||
<NotificationsContextProvider>
|
||||
<UserSettingsContextProvider>
|
||||
<AllLUGPTStatsContextProvider>
|
||||
<LoadingScreen>
|
||||
<BrowserRouter basename={basename}>
|
||||
<MaterialThemeProvider>
|
||||
<Toaster />
|
||||
<SubheaderContextProvider>
|
||||
<Routes />
|
||||
</SubheaderContextProvider>
|
||||
</MaterialThemeProvider>
|
||||
</BrowserRouter>
|
||||
</LoadingScreen>
|
||||
</AllLUGPTStatsContextProvider>
|
||||
</UserSettingsContextProvider>
|
||||
</NotificationsContextProvider>
|
||||
</UserContextProvider>
|
||||
</BannedContextProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
import { ToCDNURL } from "util/api";
|
||||
import { HistorySafeGoBack } from "util/misc";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
export function ErrorPage({
|
||||
statusCode,
|
||||
customMessage,
|
||||
}: {
|
||||
statusCode: number;
|
||||
customMessage?: string;
|
||||
}) {
|
||||
useSetSubheader(statusCode ? statusCode.toString() : "Completely Screwed.");
|
||||
|
||||
let message;
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
if (!customMessage) {
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
message = "The server refused this request.";
|
||||
break;
|
||||
case 401:
|
||||
message = "Looks like you're not authorised. Try logging in.";
|
||||
break;
|
||||
case 403:
|
||||
message = "Looks like you're not allowed to be here.";
|
||||
break;
|
||||
case 404:
|
||||
message = "This page does not exist. If you think it should exist, let us know!";
|
||||
break;
|
||||
case 429:
|
||||
message = "You're being rate limited. If this was unexpected, please report this.";
|
||||
break;
|
||||
case 500:
|
||||
message = "Looks like the server has failed. This has been reported!";
|
||||
break;
|
||||
case undefined:
|
||||
message =
|
||||
"Looks like the server has completely crashed. Ah well. Try reloading, but there's no promises.";
|
||||
break;
|
||||
default:
|
||||
message = "An unexpected error has occurred. This has been reported!";
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
message = customMessage;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fullscreen text-center"
|
||||
style={
|
||||
statusCode === 999
|
||||
? {
|
||||
background: "red",
|
||||
backgroundImage: `url(${ToCDNURL("/eggs/michael.png")})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: "cover",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="d-flex flex-column flex-root">
|
||||
<div className="d-flex flex-row-fluid flex-column bgi-size-cover bgi-position-center bgi-no-repeat p-10 p-sm-30">
|
||||
<h1
|
||||
className="font-size-sm-100 font-weight-boldest mt-15"
|
||||
style={{ fontSize: "150px" }}
|
||||
>
|
||||
{statusCode || "Completely Screwed."}
|
||||
</h1>
|
||||
<p className="font-size-h3 font-weight-light">{message}</p>
|
||||
<a className="text-primary" onClick={() => HistorySafeGoBack(history)}>
|
||||
Go Back.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { ToCDNURL } from "util/api";
|
||||
import { HistorySafeGoBack } from "util/misc";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import React from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
export function ErrorPage({
|
||||
statusCode,
|
||||
customMessage,
|
||||
}: {
|
||||
statusCode: number;
|
||||
customMessage?: string;
|
||||
}) {
|
||||
useSetSubheader(statusCode ? statusCode.toString() : "Completely Screwed.");
|
||||
|
||||
let message;
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
if (!customMessage) {
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
message = "The server refused this request.";
|
||||
break;
|
||||
case 401:
|
||||
message = "Looks like you're not authorised. Try logging in.";
|
||||
break;
|
||||
case 403:
|
||||
message = "Looks like you're not allowed to be here.";
|
||||
break;
|
||||
case 404:
|
||||
message = "This page does not exist. If you think it should exist, let us know!";
|
||||
break;
|
||||
case 429:
|
||||
message = "You're being rate limited. If this was unexpected, please report this.";
|
||||
break;
|
||||
case 500:
|
||||
message = "Looks like the server has failed. This has been reported!";
|
||||
break;
|
||||
case undefined:
|
||||
message =
|
||||
"Looks like the server has completely crashed. Ah well. Try reloading, but there's no promises.";
|
||||
break;
|
||||
default:
|
||||
message = "An unexpected error has occurred. This has been reported!";
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
message = customMessage;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fullscreen text-center"
|
||||
style={
|
||||
statusCode === 999
|
||||
? {
|
||||
background: "red",
|
||||
backgroundImage: `url(${ToCDNURL("/eggs/michael.png")})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: "cover",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="d-flex flex-column flex-root">
|
||||
<div className="d-flex flex-row-fluid flex-column bgi-size-cover bgi-position-center bgi-no-repeat p-10 p-sm-30">
|
||||
<h1
|
||||
className="font-size-sm-100 font-weight-boldest mt-15"
|
||||
style={{ fontSize: "150px" }}
|
||||
>
|
||||
{statusCode || "Completely Screwed."}
|
||||
</h1>
|
||||
<p className="font-size-h3 font-weight-light">{message}</p>
|
||||
<a className="text-primary" onClick={() => HistorySafeGoBack(history)}>
|
||||
Go Back.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,281 +1,281 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { CreateGoalMap } from "util/data";
|
||||
import { RFA } from "util/misc";
|
||||
import { NumericSOV } from "util/sorts";
|
||||
import { heySplashes } from "util/splashes";
|
||||
import Activity from "components/activity/Activity";
|
||||
import DashboardActivity from "components/dashboard/DashboardActivity";
|
||||
import { DashboardHeader } from "components/dashboard/DashboardHeader";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import SessionCard from "components/sessions/SessionCard";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import AsyncLoader from "components/util/AsyncLoader";
|
||||
import Divider from "components/util/Divider";
|
||||
import GoalLink from "components/util/GoalLink";
|
||||
import LinkButton from "components/util/LinkButton";
|
||||
import Loading from "components/util/Loading";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import { ColourConfig, TachiConfig } from "lib/config";
|
||||
import React, { useContext, useMemo } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import { Link, Route, Switch } from "react-router-dom";
|
||||
import { COLOUR_SET, GetGameConfig, UserDocument } from "tachi-common";
|
||||
import { UGSWithRankingData, UserRecentSummary } from "types/api-returns";
|
||||
import { GameStatContainer } from "./users/UserGamesPage";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
useSetSubheader("Home", [settings]);
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
if (!user) {
|
||||
return <DashboardNotLoggedIn />;
|
||||
}
|
||||
|
||||
return <DashboardLoggedIn user={user} />;
|
||||
}
|
||||
|
||||
function DashboardLoggedIn({ user }: { user: UserDocument }) {
|
||||
const splash = useMemo(() => RFA(heySplashes), [user]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="display-4">
|
||||
{splash}, {user.username}.
|
||||
</span>
|
||||
<div className="card my-4">
|
||||
<DashboardHeader />
|
||||
</div>
|
||||
<Divider />
|
||||
<RecentInfo user={user} />
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<DashboardActivity user={user} />
|
||||
</Route>
|
||||
<Route exact path="/profiles">
|
||||
<UserGameStatsInfo user={user} />
|
||||
</Route>
|
||||
<Route exact path="/global-activity">
|
||||
<Activity url="/activity" />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentInfo({ user }: { user: UserDocument }) {
|
||||
const { data, error } = useApiQuery<UserRecentSummary>(`/users/${user.id}/recent-summary`);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const folderInfoMap = new Map();
|
||||
for (const folderInfo of data.recentFolderStats) {
|
||||
folderInfoMap.set(folderInfo.folderID, folderInfo);
|
||||
}
|
||||
|
||||
const goalMap = CreateGoalMap(data.recentGoals);
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.recentSessions.length !== 0 && (
|
||||
<>
|
||||
<Alert
|
||||
style={{
|
||||
backgroundColor: `${ColourConfig.primary}44`,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h1>Today's Summary</h1>
|
||||
You've gotten <b>{data.recentPlaycount}</b> new score
|
||||
{data.recentPlaycount !== 1 ? "s" : ""} today!
|
||||
</div>
|
||||
</Alert>
|
||||
<Divider />
|
||||
<h1>New Sessions</h1>
|
||||
You've had <b>{data.recentSessions.length}</b> session
|
||||
{data.recentSessions.length !== 1 ? "s" : ""} today!
|
||||
<Divider />
|
||||
{data.recentSessions.sort(NumericSOV((x) => x.timeEnded, true)).map((e) => (
|
||||
<>
|
||||
<SessionCard sessionID={e.sessionID} key={e.sessionID} />
|
||||
<Divider />
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{data.recentAchievedGoals.length !== 0 && (
|
||||
<>
|
||||
<Alert
|
||||
style={{
|
||||
backgroundColor: ChangeOpacity(COLOUR_SET.gold, 0.2),
|
||||
}}
|
||||
>
|
||||
<div className="text-center text-white">
|
||||
<h1>
|
||||
{RFA([
|
||||
"Sweet!",
|
||||
"Nice!",
|
||||
"Lookin' good!",
|
||||
"Good Stuff!",
|
||||
"owo",
|
||||
"Cool!",
|
||||
"Awesome!",
|
||||
"Epic!",
|
||||
])}
|
||||
</h1>
|
||||
You've achieved <b>{data.recentAchievedGoals.length}</b> new goal
|
||||
{data.recentAchievedGoals.length !== 1 ? "s" : ""} today!
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="text-white">
|
||||
<ul>
|
||||
{data.recentAchievedGoals.map((e) => {
|
||||
const goal = goalMap.get(e.goalID);
|
||||
|
||||
if (!goal) {
|
||||
return <span>whoops, couldn't find this goal.</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={e.goalID}>
|
||||
<GoalLink goal={goal} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{/* {data.recentFolders.length !== 0 && (
|
||||
<>
|
||||
<h1>Here's some folders you checked out recently.</h1>
|
||||
<Divider />
|
||||
<div className="row">
|
||||
{data.recentFolders.map((e) => (
|
||||
<FolderInfoComponent
|
||||
key={e.folderID}
|
||||
folder={e}
|
||||
game={e.game}
|
||||
playtype={e.playtype}
|
||||
reqUser={user}
|
||||
folderStats={folderInfoMap.get(e.folderID)!}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserGameStatsInfo({ user }: { user: UserDocument }) {
|
||||
return (
|
||||
<div className="row">
|
||||
<AsyncLoader
|
||||
promiseFn={async () => {
|
||||
const res = await APIFetchV1<UGSWithRankingData[]>(
|
||||
`/users/${user.id}/game-stats`
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.description);
|
||||
}
|
||||
|
||||
return res.body.sort((a, b) => {
|
||||
if (a.game === b.game) {
|
||||
const gameConfig = GetGameConfig(a.game);
|
||||
|
||||
return (
|
||||
gameConfig.playtypes.indexOf(a.playtype) -
|
||||
gameConfig.playtypes.indexOf(b.playtype)
|
||||
);
|
||||
}
|
||||
|
||||
const i1 = TachiConfig.games.indexOf(a.game);
|
||||
const i2 = TachiConfig.games.indexOf(b.game);
|
||||
|
||||
return i1 - i2;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(ugs) =>
|
||||
ugs.map((e) => (
|
||||
<GameStatContainer ugs={e} reqUser={user} key={`${e.game}:${e.playtype}`} />
|
||||
))
|
||||
}
|
||||
</AsyncLoader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardNotLoggedIn() {
|
||||
return (
|
||||
<div style={{ fontSize: "1.3rem" }}>
|
||||
<h1 className="mb-4">Welcome to {TachiConfig.name}!</h1>
|
||||
<h4>
|
||||
Looks like you're not logged in. If you've got an account,{" "}
|
||||
<Link to="/login">Login!</Link>
|
||||
</h4>
|
||||
<Divider />
|
||||
<h1 className="my-4">I'm New Around Here, What is this?</h1>
|
||||
<span>
|
||||
<b>{TachiConfig.name}</b> is a Rhythm Game Score Tracker. That means we...
|
||||
</span>
|
||||
<Divider />
|
||||
<FeatureContainer
|
||||
tagline="Track Your Scores."
|
||||
description={`${TachiConfig.name} supports a bunch of your favourite games, and integrates with many existing services to make sure no score is lost to the void. Furthermore, it's backed by an Open-Source API, so your scores are always available!`}
|
||||
/>
|
||||
<FeatureContainer
|
||||
tagline="Analyse Your Scores."
|
||||
description={`${TachiConfig.name} analyses your scores for you, breaking them down into all the statistics you'll ever need. No more spreadsheets!`}
|
||||
/>
|
||||
<FeatureContainer
|
||||
tagline="Provide Cool Features."
|
||||
description={`${TachiConfig.name} implements the features rhythm gamers already talk about. Break your scores down into sessions, Showcase your best metrics on your profile, study your progress on folders - it's all there, and done for you!`}
|
||||
/>
|
||||
<Divider />
|
||||
<div className="col-12 text-center" style={{ paddingTop: 50, paddingBottom: 50 }}>
|
||||
Interested? You can register right now for <b>free</b>!
|
||||
<br />
|
||||
<LinkButton to="/register" className="mt-4 btn-outline-primary">
|
||||
Register!
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="col-12 text-center" style={{ paddingTop: 25 }}>
|
||||
Nosey? Here's what our users are up to.
|
||||
<br />
|
||||
<div style={{ fontSize: "1.0rem" }}>
|
||||
<Activity url="/activity" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureContainer({ tagline, description }: { tagline: string; description: string }) {
|
||||
return (
|
||||
<div className="row my-4 mb-16" style={{ lineHeight: "1.3", fontSize: "1.15rem" }}>
|
||||
<div className="col-12 col-lg-6">
|
||||
<h1 className="display-4">{tagline}</h1>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { ChangeOpacity } from "util/color-opacity";
|
||||
import { CreateGoalMap } from "util/data";
|
||||
import { RFA } from "util/misc";
|
||||
import { NumericSOV } from "util/sorts";
|
||||
import { heySplashes } from "util/splashes";
|
||||
import Activity from "components/activity/Activity";
|
||||
import DashboardActivity from "components/dashboard/DashboardActivity";
|
||||
import { DashboardHeader } from "components/dashboard/DashboardHeader";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import SessionCard from "components/sessions/SessionCard";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import AsyncLoader from "components/util/AsyncLoader";
|
||||
import Divider from "components/util/Divider";
|
||||
import GoalLink from "components/util/GoalLink";
|
||||
import LinkButton from "components/util/LinkButton";
|
||||
import Loading from "components/util/Loading";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import { ColourConfig, TachiConfig } from "lib/config";
|
||||
import React, { useContext, useMemo } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import { Link, Route, Switch } from "react-router-dom";
|
||||
import { COLOUR_SET, GetGameConfig, UserDocument } from "tachi-common";
|
||||
import { UGSWithRankingData, UserRecentSummary } from "types/api-returns";
|
||||
import { GameStatContainer } from "./users/UserGamesPage";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
useSetSubheader("Home", [settings]);
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
if (!user) {
|
||||
return <DashboardNotLoggedIn />;
|
||||
}
|
||||
|
||||
return <DashboardLoggedIn user={user} />;
|
||||
}
|
||||
|
||||
function DashboardLoggedIn({ user }: { user: UserDocument }) {
|
||||
const splash = useMemo(() => RFA(heySplashes), [user]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="display-4">
|
||||
{splash}, {user.username}.
|
||||
</span>
|
||||
<div className="card my-4">
|
||||
<DashboardHeader />
|
||||
</div>
|
||||
<Divider />
|
||||
<RecentInfo user={user} />
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<DashboardActivity user={user} />
|
||||
</Route>
|
||||
<Route exact path="/profiles">
|
||||
<UserGameStatsInfo user={user} />
|
||||
</Route>
|
||||
<Route exact path="/global-activity">
|
||||
<Activity url="/activity" />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentInfo({ user }: { user: UserDocument }) {
|
||||
const { data, error } = useApiQuery<UserRecentSummary>(`/users/${user.id}/recent-summary`);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const folderInfoMap = new Map();
|
||||
for (const folderInfo of data.recentFolderStats) {
|
||||
folderInfoMap.set(folderInfo.folderID, folderInfo);
|
||||
}
|
||||
|
||||
const goalMap = CreateGoalMap(data.recentGoals);
|
||||
|
||||
return (
|
||||
<>
|
||||
{data.recentSessions.length !== 0 && (
|
||||
<>
|
||||
<Alert
|
||||
style={{
|
||||
backgroundColor: `${ColourConfig.primary}44`,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h1>Today's Summary</h1>
|
||||
You've gotten <b>{data.recentPlaycount}</b> new score
|
||||
{data.recentPlaycount !== 1 ? "s" : ""} today!
|
||||
</div>
|
||||
</Alert>
|
||||
<Divider />
|
||||
<h1>New Sessions</h1>
|
||||
You've had <b>{data.recentSessions.length}</b> session
|
||||
{data.recentSessions.length !== 1 ? "s" : ""} today!
|
||||
<Divider />
|
||||
{data.recentSessions.sort(NumericSOV((x) => x.timeEnded, true)).map((e) => (
|
||||
<>
|
||||
<SessionCard sessionID={e.sessionID} key={e.sessionID} />
|
||||
<Divider />
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{data.recentAchievedGoals.length !== 0 && (
|
||||
<>
|
||||
<Alert
|
||||
style={{
|
||||
backgroundColor: ChangeOpacity(COLOUR_SET.gold, 0.2),
|
||||
}}
|
||||
>
|
||||
<div className="text-center text-white">
|
||||
<h1>
|
||||
{RFA([
|
||||
"Sweet!",
|
||||
"Nice!",
|
||||
"Lookin' good!",
|
||||
"Good Stuff!",
|
||||
"owo",
|
||||
"Cool!",
|
||||
"Awesome!",
|
||||
"Epic!",
|
||||
])}
|
||||
</h1>
|
||||
You've achieved <b>{data.recentAchievedGoals.length}</b> new goal
|
||||
{data.recentAchievedGoals.length !== 1 ? "s" : ""} today!
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="text-white">
|
||||
<ul>
|
||||
{data.recentAchievedGoals.map((e) => {
|
||||
const goal = goalMap.get(e.goalID);
|
||||
|
||||
if (!goal) {
|
||||
return <span>whoops, couldn't find this goal.</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={e.goalID}>
|
||||
<GoalLink goal={goal} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{/* {data.recentFolders.length !== 0 && (
|
||||
<>
|
||||
<h1>Here's some folders you checked out recently.</h1>
|
||||
<Divider />
|
||||
<div className="row">
|
||||
{data.recentFolders.map((e) => (
|
||||
<FolderInfoComponent
|
||||
key={e.folderID}
|
||||
folder={e}
|
||||
game={e.game}
|
||||
playtype={e.playtype}
|
||||
reqUser={user}
|
||||
folderStats={folderInfoMap.get(e.folderID)!}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserGameStatsInfo({ user }: { user: UserDocument }) {
|
||||
return (
|
||||
<div className="row">
|
||||
<AsyncLoader
|
||||
promiseFn={async () => {
|
||||
const res = await APIFetchV1<UGSWithRankingData[]>(
|
||||
`/users/${user.id}/game-stats`
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
throw new Error(res.description);
|
||||
}
|
||||
|
||||
return res.body.sort((a, b) => {
|
||||
if (a.game === b.game) {
|
||||
const gameConfig = GetGameConfig(a.game);
|
||||
|
||||
return (
|
||||
gameConfig.playtypes.indexOf(a.playtype) -
|
||||
gameConfig.playtypes.indexOf(b.playtype)
|
||||
);
|
||||
}
|
||||
|
||||
const i1 = TachiConfig.games.indexOf(a.game);
|
||||
const i2 = TachiConfig.games.indexOf(b.game);
|
||||
|
||||
return i1 - i2;
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(ugs) =>
|
||||
ugs.map((e) => (
|
||||
<GameStatContainer ugs={e} reqUser={user} key={`${e.game}:${e.playtype}`} />
|
||||
))
|
||||
}
|
||||
</AsyncLoader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardNotLoggedIn() {
|
||||
return (
|
||||
<div style={{ fontSize: "1.3rem" }}>
|
||||
<h1 className="mb-4">Welcome to {TachiConfig.name}!</h1>
|
||||
<h4>
|
||||
Looks like you're not logged in. If you've got an account,{" "}
|
||||
<Link to="/login">Login!</Link>
|
||||
</h4>
|
||||
<Divider />
|
||||
<h1 className="my-4">I'm New Around Here, What is this?</h1>
|
||||
<span>
|
||||
<b>{TachiConfig.name}</b> is a Rhythm Game Score Tracker. That means we...
|
||||
</span>
|
||||
<Divider />
|
||||
<FeatureContainer
|
||||
tagline="Track Your Scores."
|
||||
description={`${TachiConfig.name} supports a bunch of your favourite games, and integrates with many existing services to make sure no score is lost to the void. Furthermore, it's backed by an Open-Source API, so your scores are always available!`}
|
||||
/>
|
||||
<FeatureContainer
|
||||
tagline="Analyse Your Scores."
|
||||
description={`${TachiConfig.name} analyses your scores for you, breaking them down into all the statistics you'll ever need. No more spreadsheets!`}
|
||||
/>
|
||||
<FeatureContainer
|
||||
tagline="Provide Cool Features."
|
||||
description={`${TachiConfig.name} implements the features rhythm gamers already talk about. Break your scores down into sessions, Showcase your best metrics on your profile, study your progress on folders - it's all there, and done for you!`}
|
||||
/>
|
||||
<Divider />
|
||||
<div className="col-12 text-center" style={{ paddingTop: 50, paddingBottom: 50 }}>
|
||||
Interested? You can register right now for <b>free</b>!
|
||||
<br />
|
||||
<LinkButton to="/register" className="mt-4 btn-outline-primary">
|
||||
Register!
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="col-12 text-center" style={{ paddingTop: 25 }}>
|
||||
Nosey? Here's what our users are up to.
|
||||
<br />
|
||||
<div style={{ fontSize: "1.0rem" }}>
|
||||
<Activity url="/activity" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureContainer({ tagline, description }: { tagline: string; description: string }) {
|
||||
return (
|
||||
<div className="row my-4 mb-16" style={{ lineHeight: "1.3", fontSize: "1.15rem" }}>
|
||||
<div className="col-12 col-lg-6">
|
||||
<h1 className="display-4">{tagline}</h1>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,95 +1,95 @@
|
||||
import { HistorySafeGoBack } from "util/misc";
|
||||
import { ErrorPage } from "app/pages/ErrorPage";
|
||||
import ForgotPasswordPage from "app/pages/ForgotPasswordPage";
|
||||
import LoginPage from "app/pages/LoginPage";
|
||||
import OAuthRequestAuthPage from "app/pages/OAuthRequestAuthPage";
|
||||
import RegisterPage from "app/pages/RegisterPage";
|
||||
import ResetPasswordPage from "app/pages/ResetPasswordPage";
|
||||
import VerifyEmailPage from "app/pages/VerifyEmailPage";
|
||||
import CenterPage from "components/util/CenterPage";
|
||||
import ErrorBoundary from "components/util/ErrorBoundary";
|
||||
import MainPageTitleContainer from "components/util/MainPageTitleContainer";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { ClientConfig } from "lib/config";
|
||||
import React, { useContext } from "react";
|
||||
import { Redirect, Route, Switch, useHistory } from "react-router-dom";
|
||||
import ClientFileFlowRoutes from "./ClientFileFlowRoutes";
|
||||
import DashboardRoutes from "./DashboardRoutes";
|
||||
import OAuth2CallbackRoutes from "./OAuth2CallbackRoutes";
|
||||
import RedirectLegacyRoutes from "./RedirectLegacyRoutes";
|
||||
|
||||
/**
|
||||
* Core Routes for Tachi-Client.
|
||||
* These are things that involve redirecting to non-dashboard apps.
|
||||
*/
|
||||
export function Routes() {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Switch>
|
||||
{/* redirects for legacy urls */}
|
||||
<Route path="/dashboard">
|
||||
<RedirectLegacyRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/michael">
|
||||
<ErrorPage statusCode={999} customMessage="He has been summoned." />
|
||||
</Route>
|
||||
|
||||
<Route path="/verify-email">
|
||||
<VerifyEmailPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/oauth2-callback">
|
||||
<OAuth2CallbackRoutes />
|
||||
</Route>
|
||||
<Route path="/oauth/request-auth">
|
||||
<OAuthRequestAuthPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/client-file-flow">
|
||||
<ClientFileFlowRoutes />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/login">
|
||||
{user ? <Redirect to="/" /> : <LoginPage />}
|
||||
</Route>
|
||||
<Route exact path="/register">
|
||||
{user ? <Redirect to="/" /> : <RegisterPage />}
|
||||
</Route>
|
||||
<Route exact path="/forgot-password">
|
||||
{user ? <Redirect to="/" /> : <ForgotPasswordPage />}
|
||||
</Route>
|
||||
<Route exact path="/reset-password">
|
||||
{user ? <Redirect to="/" /> : <ResetPasswordPage />}
|
||||
</Route>
|
||||
<Route exact path="/screwed">
|
||||
{user ? (
|
||||
<Redirect to="/" />
|
||||
) : (
|
||||
<CenterPage>
|
||||
<MainPageTitleContainer
|
||||
title="You can't."
|
||||
desc="If you signed up with a fake email, you're now locked out of this account. Nice one."
|
||||
/>
|
||||
<span
|
||||
onClick={() => HistorySafeGoBack(history)}
|
||||
tabIndex={4}
|
||||
className="btn btn-outline-danger"
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
</CenterPage>
|
||||
)}
|
||||
</Route>
|
||||
|
||||
<Route path="*">
|
||||
{!user && ClientConfig.MANDATE_LOGIN ? <LoginPage /> : <DashboardRoutes />}
|
||||
</Route>
|
||||
</Switch>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
import { HistorySafeGoBack } from "util/misc";
|
||||
import { ErrorPage } from "app/pages/ErrorPage";
|
||||
import ForgotPasswordPage from "app/pages/ForgotPasswordPage";
|
||||
import LoginPage from "app/pages/LoginPage";
|
||||
import OAuthRequestAuthPage from "app/pages/OAuthRequestAuthPage";
|
||||
import RegisterPage from "app/pages/RegisterPage";
|
||||
import ResetPasswordPage from "app/pages/ResetPasswordPage";
|
||||
import VerifyEmailPage from "app/pages/VerifyEmailPage";
|
||||
import CenterPage from "components/util/CenterPage";
|
||||
import ErrorBoundary from "components/util/ErrorBoundary";
|
||||
import MainPageTitleContainer from "components/util/MainPageTitleContainer";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { ClientConfig } from "lib/config";
|
||||
import React, { useContext } from "react";
|
||||
import { Redirect, Route, Switch, useHistory } from "react-router-dom";
|
||||
import ClientFileFlowRoutes from "./ClientFileFlowRoutes";
|
||||
import DashboardRoutes from "./DashboardRoutes";
|
||||
import OAuth2CallbackRoutes from "./OAuth2CallbackRoutes";
|
||||
import RedirectLegacyRoutes from "./RedirectLegacyRoutes";
|
||||
|
||||
/**
|
||||
* Core Routes for Tachi-Client.
|
||||
* These are things that involve redirecting to non-dashboard apps.
|
||||
*/
|
||||
export function Routes() {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Switch>
|
||||
{/* redirects for legacy urls */}
|
||||
<Route path="/dashboard">
|
||||
<RedirectLegacyRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/michael">
|
||||
<ErrorPage statusCode={999} customMessage="He has been summoned." />
|
||||
</Route>
|
||||
|
||||
<Route path="/verify-email">
|
||||
<VerifyEmailPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/oauth2-callback">
|
||||
<OAuth2CallbackRoutes />
|
||||
</Route>
|
||||
<Route path="/oauth/request-auth">
|
||||
<OAuthRequestAuthPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/client-file-flow">
|
||||
<ClientFileFlowRoutes />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/login">
|
||||
{user ? <Redirect to="/" /> : <LoginPage />}
|
||||
</Route>
|
||||
<Route exact path="/register">
|
||||
{user ? <Redirect to="/" /> : <RegisterPage />}
|
||||
</Route>
|
||||
<Route exact path="/forgot-password">
|
||||
{user ? <Redirect to="/" /> : <ForgotPasswordPage />}
|
||||
</Route>
|
||||
<Route exact path="/reset-password">
|
||||
{user ? <Redirect to="/" /> : <ResetPasswordPage />}
|
||||
</Route>
|
||||
<Route exact path="/screwed">
|
||||
{user ? (
|
||||
<Redirect to="/" />
|
||||
) : (
|
||||
<CenterPage>
|
||||
<MainPageTitleContainer
|
||||
title="You can't."
|
||||
desc="If you signed up with a fake email, you're now locked out of this account. Nice one."
|
||||
/>
|
||||
<span
|
||||
onClick={() => HistorySafeGoBack(history)}
|
||||
tabIndex={4}
|
||||
className="btn btn-outline-danger"
|
||||
>
|
||||
Back
|
||||
</span>
|
||||
</CenterPage>
|
||||
)}
|
||||
</Route>
|
||||
|
||||
<Route path="*">
|
||||
{!user && ClientConfig.MANDATE_LOGIN ? <LoginPage /> : <DashboardRoutes />}
|
||||
</Route>
|
||||
</Switch>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,134 +1,134 @@
|
||||
import { APIFetchV1, ToAPIURL } from "util/api";
|
||||
import SupportMePage from "app/pages/dashboard/misc/SupportMePage";
|
||||
import { ErrorPage } from "app/pages/ErrorPage";
|
||||
import PrivacyPolicyPage from "app/pages/PrivacyPolicyPage";
|
||||
import { Layout } from "components/layout/Layout";
|
||||
import EmailVerify from "components/layout/misc/EmailVerify";
|
||||
import DashboardErrorBoundary from "components/util/DashboardErrorBoundary";
|
||||
import { BannedContext } from "context/BannedContext";
|
||||
import { UserContext } from "context/UserContext";
|
||||
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";
|
||||
import ImportRoutes from "./ImportRoutes";
|
||||
import UserRoutes from "./UserRoutes";
|
||||
import UtilRoutes from "./UtilRoutes";
|
||||
import { RedirectLegacyUserRoutes } from "./RedirectLegacyRoutes";
|
||||
|
||||
export default function DashboardRoutes() {
|
||||
const { user } = useContext(UserContext);
|
||||
const { banned } = useContext(BannedContext);
|
||||
const { setBackground } = useContext(BackgroundContext);
|
||||
|
||||
const [hasVerifiedEmail, setHasVerifiedEmail] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return setHasVerifiedEmail(null);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const hasVerified = await APIFetchV1<boolean>(`/users/${user.id}/is-email-verified`);
|
||||
|
||||
if (hasVerified.success) {
|
||||
setHasVerifiedEmail(hasVerified.body);
|
||||
}
|
||||
})();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setBackground(ToAPIURL(`/users/${user.id}/banner`));
|
||||
} else {
|
||||
setBackground(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
setBackground(null);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
if (hasVerifiedEmail === false) {
|
||||
return (
|
||||
<Layout>
|
||||
<EmailVerify setHasVerifiedEmail={setHasVerifiedEmail} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (banned) {
|
||||
return <ErrorPage statusCode={403} customMessage="You are banned." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<DashboardErrorBoundary>
|
||||
<Switch>
|
||||
{/* this is the easiest (read: least sucky) way of handling */}
|
||||
{/* these routes */}
|
||||
<Route exact path={["/", "/profiles", "/global-activity"]}>
|
||||
<DashboardPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/search">
|
||||
<SearchPage />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/privacy">
|
||||
<PrivacyPolicyPage />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/credits">
|
||||
<CreditsPage />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/support">
|
||||
<SupportMePage />
|
||||
</Route>
|
||||
|
||||
{/* this used to be called /dashboard/users/username, now it's called /u/username */}
|
||||
<Route path="/users">
|
||||
<RedirectLegacyUserRoutes />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/u">
|
||||
<Redirect to="/" />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/g">
|
||||
<Redirect to="/" />
|
||||
</Route>
|
||||
|
||||
<Route path="/u/:userID">
|
||||
<UserRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/games/:game">
|
||||
<GameRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/import">
|
||||
<ImportRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/utils">
|
||||
<UtilRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/notifications">
|
||||
<NotificationsPage />
|
||||
</Route>
|
||||
|
||||
<Route path="*">
|
||||
<ErrorPage statusCode={404} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</DashboardErrorBoundary>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
import { APIFetchV1, ToAPIURL } from "util/api";
|
||||
import SupportMePage from "app/pages/dashboard/misc/SupportMePage";
|
||||
import { ErrorPage } from "app/pages/ErrorPage";
|
||||
import PrivacyPolicyPage from "app/pages/PrivacyPolicyPage";
|
||||
import { Layout } from "components/layout/Layout";
|
||||
import EmailVerify from "components/layout/misc/EmailVerify";
|
||||
import DashboardErrorBoundary from "components/util/DashboardErrorBoundary";
|
||||
import { BannedContext } from "context/BannedContext";
|
||||
import { UserContext } from "context/UserContext";
|
||||
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";
|
||||
import ImportRoutes from "./ImportRoutes";
|
||||
import UserRoutes from "./UserRoutes";
|
||||
import UtilRoutes from "./UtilRoutes";
|
||||
import { RedirectLegacyUserRoutes } from "./RedirectLegacyRoutes";
|
||||
|
||||
export default function DashboardRoutes() {
|
||||
const { user } = useContext(UserContext);
|
||||
const { banned } = useContext(BannedContext);
|
||||
const { setBackground } = useContext(BackgroundContext);
|
||||
|
||||
const [hasVerifiedEmail, setHasVerifiedEmail] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return setHasVerifiedEmail(null);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const hasVerified = await APIFetchV1<boolean>(`/users/${user.id}/is-email-verified`);
|
||||
|
||||
if (hasVerified.success) {
|
||||
setHasVerifiedEmail(hasVerified.body);
|
||||
}
|
||||
})();
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setBackground(ToAPIURL(`/users/${user.id}/banner`));
|
||||
} else {
|
||||
setBackground(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
setBackground(null);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
if (hasVerifiedEmail === false) {
|
||||
return (
|
||||
<Layout>
|
||||
<EmailVerify setHasVerifiedEmail={setHasVerifiedEmail} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (banned) {
|
||||
return <ErrorPage statusCode={403} customMessage="You are banned." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<DashboardErrorBoundary>
|
||||
<Switch>
|
||||
{/* this is the easiest (read: least sucky) way of handling */}
|
||||
{/* these routes */}
|
||||
<Route exact path={["/", "/profiles", "/global-activity"]}>
|
||||
<DashboardPage />
|
||||
</Route>
|
||||
|
||||
<Route path="/search">
|
||||
<SearchPage />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/privacy">
|
||||
<PrivacyPolicyPage />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/credits">
|
||||
<CreditsPage />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/support">
|
||||
<SupportMePage />
|
||||
</Route>
|
||||
|
||||
{/* this used to be called /dashboard/users/username, now it's called /u/username */}
|
||||
<Route path="/users">
|
||||
<RedirectLegacyUserRoutes />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/u">
|
||||
<Redirect to="/" />
|
||||
</Route>
|
||||
|
||||
<Route exact path="/g">
|
||||
<Redirect to="/" />
|
||||
</Route>
|
||||
|
||||
<Route path="/u/:userID">
|
||||
<UserRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/games/:game">
|
||||
<GameRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/import">
|
||||
<ImportRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/utils">
|
||||
<UtilRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/notifications">
|
||||
<NotificationsPage />
|
||||
</Route>
|
||||
|
||||
<Route path="*">
|
||||
<ErrorPage statusCode={404} />
|
||||
</Route>
|
||||
</Switch>
|
||||
</DashboardErrorBoundary>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import BackgroundImage from "components/layout/misc/BackgroundImage";
|
||||
import { BackgroundContextProvider } from "context/BackgroundContext";
|
||||
import React, { useState } from "react";
|
||||
import { JustChildren } from "types/react";
|
||||
import { Footer } from "./footer/Footer";
|
||||
import { Header } from "./header/Header";
|
||||
import { HeaderMobile } from "./header/HeaderMobile";
|
||||
import { SubHeader } from "./subheader/SubHeader";
|
||||
|
||||
export function Layout({ children }: JustChildren) {
|
||||
const [mobileShow, setMobileShow] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderMobile setMobileShow={setMobileShow} mobileShow={mobileShow} />
|
||||
|
||||
<div className="d-flex flex-column flex-root">
|
||||
<div className="d-flex flex-row flex-column-fluid page">
|
||||
<div
|
||||
className="d-flex flex-column flex-row-fluid wrapper"
|
||||
id="kt_wrapper"
|
||||
style={{ overflowX: "hidden" }}
|
||||
>
|
||||
<Header mobileShow={mobileShow} setMobileShow={setMobileShow} />
|
||||
|
||||
<BackgroundContextProvider>
|
||||
<BackgroundImage />
|
||||
|
||||
<div
|
||||
id="kt_content"
|
||||
className="content d-flex flex-column flex-column-fluid"
|
||||
>
|
||||
<SubHeader />
|
||||
|
||||
<div className="container">{children}</div>
|
||||
</div>
|
||||
</BackgroundContextProvider>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import BackgroundImage from "components/layout/misc/BackgroundImage";
|
||||
import { BackgroundContextProvider } from "context/BackgroundContext";
|
||||
import React, { useState } from "react";
|
||||
import { JustChildren } from "types/react";
|
||||
import { Footer } from "./footer/Footer";
|
||||
import { Header } from "./header/Header";
|
||||
import { HeaderMobile } from "./header/HeaderMobile";
|
||||
import { SubHeader } from "./subheader/SubHeader";
|
||||
|
||||
export function Layout({ children }: JustChildren) {
|
||||
const [mobileShow, setMobileShow] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderMobile setMobileShow={setMobileShow} mobileShow={mobileShow} />
|
||||
|
||||
<div className="d-flex flex-column flex-root">
|
||||
<div className="d-flex flex-row flex-column-fluid page">
|
||||
<div
|
||||
className="d-flex flex-column flex-row-fluid wrapper"
|
||||
id="kt_wrapper"
|
||||
style={{ overflowX: "hidden" }}
|
||||
>
|
||||
<Header mobileShow={mobileShow} setMobileShow={setMobileShow} />
|
||||
|
||||
<BackgroundContextProvider>
|
||||
<BackgroundImage />
|
||||
|
||||
<div
|
||||
id="kt_content"
|
||||
className="content d-flex flex-column flex-column-fluid"
|
||||
>
|
||||
<SubHeader />
|
||||
|
||||
<div className="container">{children}</div>
|
||||
</div>
|
||||
</BackgroundContextProvider>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +1,102 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import Divider from "components/util/Divider";
|
||||
import ExternalLink from "components/util/ExternalLink";
|
||||
import { BannedContext } from "context/BannedContext";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ServerStatus } from "types/api-returns";
|
||||
|
||||
export function Footer() {
|
||||
const [serverVersion, setServerVersion] = useState("Loading...");
|
||||
const { setBanned } = useContext(BannedContext);
|
||||
|
||||
useEffect(() => {
|
||||
APIFetchV1<ServerStatus>("/status").then((r) => {
|
||||
if (r.statusCode === 403) {
|
||||
setBanned(true);
|
||||
}
|
||||
|
||||
if (!r.success) {
|
||||
setServerVersion("Error Fetching data!");
|
||||
} else {
|
||||
setServerVersion(r.body.version);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider className="mt-8" />
|
||||
|
||||
<div className="footer py-4 d-flex flex-lg-column" id="kt_footer">
|
||||
<div className="container d-flex flex-column flex-md-row align-items-center justify-content-between">
|
||||
<div className="order-2 order-md-1">
|
||||
{/* is there a better way to do this? mt-md-3 is the intent */}
|
||||
<ExternalLink
|
||||
href="https://en.wikipedia.org/wiki/Dummy_(album)"
|
||||
className="gentle-link"
|
||||
>
|
||||
{serverVersion}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
|
||||
<div className="nav nav-dark order-1 order-md-2 justify-content-center">
|
||||
<Link
|
||||
to="/support"
|
||||
className="nav-link px-3"
|
||||
onClick={() => window.scrollTo(0, 0)}
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
<ExternalLink
|
||||
href="https://docs.bokutachi.xyz/wiki/rules"
|
||||
className="nav-link px-3"
|
||||
>
|
||||
Rules
|
||||
</ExternalLink>
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="nav-link px-3"
|
||||
onClick={() => window.scrollTo(0, 0)}
|
||||
>
|
||||
GDPR
|
||||
</Link>
|
||||
<Link
|
||||
to="/credits"
|
||||
className="nav-link px-3"
|
||||
onClick={() => window.scrollTo(0, 0)}
|
||||
>
|
||||
Credits
|
||||
</Link>
|
||||
{process.env.VITE_DISCORD && (
|
||||
<a
|
||||
href={process.env.VITE_DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-link px-3"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/tng-dev/tachi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-link px-3"
|
||||
>
|
||||
Source Code
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.bokutachi.xyz/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-link px-3"
|
||||
>
|
||||
Developer Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import Divider from "components/util/Divider";
|
||||
import ExternalLink from "components/util/ExternalLink";
|
||||
import { BannedContext } from "context/BannedContext";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ServerStatus } from "types/api-returns";
|
||||
|
||||
export function Footer() {
|
||||
const [serverVersion, setServerVersion] = useState("Loading...");
|
||||
const { setBanned } = useContext(BannedContext);
|
||||
|
||||
useEffect(() => {
|
||||
APIFetchV1<ServerStatus>("/status").then((r) => {
|
||||
if (r.statusCode === 403) {
|
||||
setBanned(true);
|
||||
}
|
||||
|
||||
if (!r.success) {
|
||||
setServerVersion("Error Fetching data!");
|
||||
} else {
|
||||
setServerVersion(r.body.version);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider className="mt-8" />
|
||||
|
||||
<div className="footer py-4 d-flex flex-lg-column" id="kt_footer">
|
||||
<div className="container d-flex flex-column flex-md-row align-items-center justify-content-between">
|
||||
<div className="order-2 order-md-1">
|
||||
{/* is there a better way to do this? mt-md-3 is the intent */}
|
||||
<ExternalLink
|
||||
href="https://en.wikipedia.org/wiki/Dummy_(album)"
|
||||
className="gentle-link"
|
||||
>
|
||||
{serverVersion}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
|
||||
<div className="nav nav-dark order-1 order-md-2 justify-content-center">
|
||||
<Link
|
||||
to="/support"
|
||||
className="nav-link px-3"
|
||||
onClick={() => window.scrollTo(0, 0)}
|
||||
>
|
||||
Support
|
||||
</Link>
|
||||
<ExternalLink
|
||||
href="https://docs.bokutachi.xyz/wiki/rules"
|
||||
className="nav-link px-3"
|
||||
>
|
||||
Rules
|
||||
</ExternalLink>
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="nav-link px-3"
|
||||
onClick={() => window.scrollTo(0, 0)}
|
||||
>
|
||||
GDPR
|
||||
</Link>
|
||||
<Link
|
||||
to="/credits"
|
||||
className="nav-link px-3"
|
||||
onClick={() => window.scrollTo(0, 0)}
|
||||
>
|
||||
Credits
|
||||
</Link>
|
||||
{process.env.VITE_DISCORD && (
|
||||
<a
|
||||
href={process.env.VITE_DISCORD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-link px-3"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/tng-dev/tachi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-link px-3"
|
||||
>
|
||||
Source Code
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.bokutachi.xyz/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-link px-3"
|
||||
>
|
||||
Developer Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { ToCDNURL } from "util/api";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { SetState } from "types/react";
|
||||
import { HeaderMenu } from "./HeaderMenu";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
export function Header({
|
||||
mobileShow,
|
||||
setMobileShow,
|
||||
}: {
|
||||
mobileShow: boolean;
|
||||
setMobileShow: SetState<boolean>;
|
||||
}) {
|
||||
return (
|
||||
<div className="header header-fixed" id="kt_header">
|
||||
<div className="container d-flex align-items-stretch justify-content-between">
|
||||
<div className="d-flex align-items-stretch mr-3">
|
||||
<div className="header-logo">
|
||||
<Link to="/">
|
||||
<img
|
||||
className="logo-default max-h-40px"
|
||||
alt="Logo"
|
||||
src={ToCDNURL("/logos/logo-mark.png")}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<HeaderMenu mobileShow={mobileShow} setMobileShow={setMobileShow} />
|
||||
</div>
|
||||
|
||||
<Topbar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { ToCDNURL } from "util/api";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { SetState } from "types/react";
|
||||
import { HeaderMenu } from "./HeaderMenu";
|
||||
import { Topbar } from "./Topbar";
|
||||
|
||||
export function Header({
|
||||
mobileShow,
|
||||
setMobileShow,
|
||||
}: {
|
||||
mobileShow: boolean;
|
||||
setMobileShow: SetState<boolean>;
|
||||
}) {
|
||||
return (
|
||||
<div className="header header-fixed" id="kt_header">
|
||||
<div className="container d-flex align-items-stretch justify-content-between">
|
||||
<div className="d-flex align-items-stretch mr-3">
|
||||
<div className="header-logo">
|
||||
<Link to="/">
|
||||
<img
|
||||
className="logo-default max-h-40px"
|
||||
alt="Logo"
|
||||
src={ToCDNURL("/logos/logo-mark.png")}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<HeaderMenu mobileShow={mobileShow} setMobileShow={setMobileShow} />
|
||||
</div>
|
||||
|
||||
<Topbar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,171 +1,171 @@
|
||||
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 { 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 AllGames from "./AllGames";
|
||||
import ImportScoresLink from "./ImportScoresLink";
|
||||
import MenuDropdown from "./MenuDropdown";
|
||||
import MenuLink from "./MenuLink";
|
||||
import UtilsDropdown from "./UtilsDropdown";
|
||||
|
||||
export function HeaderMenu({
|
||||
mobileShow,
|
||||
setMobileShow,
|
||||
}: {
|
||||
mobileShow: boolean;
|
||||
setMobileShow: SetState<boolean>;
|
||||
}) {
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
const { ugs, setUGS } = useContext(AllLUGPTStatsContext);
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
const { isLoading, error } = useQuery([user?.id, "game_stats"], async () => {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await APIFetchV1<UserGameStats[]>("/users/me/game-stats");
|
||||
|
||||
if (!res.success) {
|
||||
throw res;
|
||||
}
|
||||
|
||||
return setUGS(res.body);
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const { ref, isComponentVisible, setIsComponentVisible } = useComponentVisible(mobileShow);
|
||||
|
||||
// how does this not cause a problem. What?
|
||||
useEffect(() => {
|
||||
setIsComponentVisible(mobileShow);
|
||||
}, [mobileShow]);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileShow(isComponentVisible);
|
||||
}, [isComponentVisible]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const userProfileLinks = [];
|
||||
|
||||
if (user && ugs && ugs.length !== 0) {
|
||||
const ugsMap = new Map();
|
||||
for (const s of ugs) {
|
||||
ugsMap.set(`${s.game}:${s.playtype}`, s);
|
||||
}
|
||||
|
||||
for (const game of TachiConfig.games) {
|
||||
for (const playtype of GetGameConfig(game).playtypes) {
|
||||
const e = ugsMap.get(`${game}:${playtype}`);
|
||||
|
||||
if (!e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
userProfileLinks.push(
|
||||
<MenuLink
|
||||
key={`${e.game}:${e.playtype}`}
|
||||
name={FormatGame(e.game, e.playtype)}
|
||||
to={`/u/${user.username}/games/${e.game}/${e.playtype}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`header-menu-wrapper header-menu-wrapper-left ${
|
||||
isComponentVisible ? "header-menu-wrapper-on" : ""
|
||||
}`}
|
||||
ref={ref}
|
||||
id="kt_header_menu_wrapper"
|
||||
>
|
||||
<div className="d-flex h-100" style={{ flexDirection: "column" }}>
|
||||
<div
|
||||
id="kt_header_menu"
|
||||
className="header-menu header-menu-left header-menu-mobile header-menu-layout-default header-menu-root-arrow"
|
||||
>
|
||||
<div className="d-lg-none">
|
||||
<Link to="/">
|
||||
<img
|
||||
src={ToCDNURL("/logos/logo-wordmark.png")}
|
||||
width="100%"
|
||||
className="px-10 mt-4"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="mt-4">
|
||||
<Divider />
|
||||
</div>
|
||||
</div>
|
||||
<ul className="menu-nav">
|
||||
{user && ugs && ugs.length !== 0 && (
|
||||
<MenuDropdown name="Your Profiles">{userProfileLinks}</MenuDropdown>
|
||||
)}
|
||||
|
||||
<AllGames />
|
||||
|
||||
{user && <ImportScoresLink />}
|
||||
|
||||
{settings?.preferences.developerMode && <UtilsDropdown />}
|
||||
</ul>
|
||||
</div>
|
||||
{user && (
|
||||
<div
|
||||
className="d-flex d-lg-none"
|
||||
style={{ paddingLeft: "30px", marginBottom: "2rem", flex: "1 1 auto" }}
|
||||
>
|
||||
<div>
|
||||
<Divider />
|
||||
</div>
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
variant="outline-danger"
|
||||
onClick={async () => {
|
||||
if (confirm("Are you sure you want to sign out?")) {
|
||||
const rj = await APIFetchV1("/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (rj.success) {
|
||||
toast.success("Logged out.");
|
||||
setTimeout(() => {
|
||||
setUser(null);
|
||||
localStorage.removeItem("isLoggedIn");
|
||||
// This has to be the case.
|
||||
// Otherwise, react just ruins its own
|
||||
// state. I hate react state.
|
||||
window.location.href = "/";
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon type="sign-out-alt" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 { 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 AllGames from "./AllGames";
|
||||
import ImportScoresLink from "./ImportScoresLink";
|
||||
import MenuDropdown from "./MenuDropdown";
|
||||
import MenuLink from "./MenuLink";
|
||||
import UtilsDropdown from "./UtilsDropdown";
|
||||
|
||||
export function HeaderMenu({
|
||||
mobileShow,
|
||||
setMobileShow,
|
||||
}: {
|
||||
mobileShow: boolean;
|
||||
setMobileShow: SetState<boolean>;
|
||||
}) {
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
const { ugs, setUGS } = useContext(AllLUGPTStatsContext);
|
||||
const { settings } = useContext(UserSettingsContext);
|
||||
|
||||
const { isLoading, error } = useQuery([user?.id, "game_stats"], async () => {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await APIFetchV1<UserGameStats[]>("/users/me/game-stats");
|
||||
|
||||
if (!res.success) {
|
||||
throw res;
|
||||
}
|
||||
|
||||
return setUGS(res.body);
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const { ref, isComponentVisible, setIsComponentVisible } = useComponentVisible(mobileShow);
|
||||
|
||||
// how does this not cause a problem. What?
|
||||
useEffect(() => {
|
||||
setIsComponentVisible(mobileShow);
|
||||
}, [mobileShow]);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileShow(isComponentVisible);
|
||||
}, [isComponentVisible]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const userProfileLinks = [];
|
||||
|
||||
if (user && ugs && ugs.length !== 0) {
|
||||
const ugsMap = new Map();
|
||||
for (const s of ugs) {
|
||||
ugsMap.set(`${s.game}:${s.playtype}`, s);
|
||||
}
|
||||
|
||||
for (const game of TachiConfig.games) {
|
||||
for (const playtype of GetGameConfig(game).playtypes) {
|
||||
const e = ugsMap.get(`${game}:${playtype}`);
|
||||
|
||||
if (!e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
userProfileLinks.push(
|
||||
<MenuLink
|
||||
key={`${e.game}:${e.playtype}`}
|
||||
name={FormatGame(e.game, e.playtype)}
|
||||
to={`/u/${user.username}/games/${e.game}/${e.playtype}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`header-menu-wrapper header-menu-wrapper-left ${
|
||||
isComponentVisible ? "header-menu-wrapper-on" : ""
|
||||
}`}
|
||||
ref={ref}
|
||||
id="kt_header_menu_wrapper"
|
||||
>
|
||||
<div className="d-flex h-100" style={{ flexDirection: "column" }}>
|
||||
<div
|
||||
id="kt_header_menu"
|
||||
className="header-menu header-menu-left header-menu-mobile header-menu-layout-default header-menu-root-arrow"
|
||||
>
|
||||
<div className="d-lg-none">
|
||||
<Link to="/">
|
||||
<img
|
||||
src={ToCDNURL("/logos/logo-wordmark.png")}
|
||||
width="100%"
|
||||
className="px-10 mt-4"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="mt-4">
|
||||
<Divider />
|
||||
</div>
|
||||
</div>
|
||||
<ul className="menu-nav">
|
||||
{user && ugs && ugs.length !== 0 && (
|
||||
<MenuDropdown name="Your Profiles">{userProfileLinks}</MenuDropdown>
|
||||
)}
|
||||
|
||||
<AllGames />
|
||||
|
||||
{user && <ImportScoresLink />}
|
||||
|
||||
{settings?.preferences.developerMode && <UtilsDropdown />}
|
||||
</ul>
|
||||
</div>
|
||||
{user && (
|
||||
<div
|
||||
className="d-flex d-lg-none"
|
||||
style={{ paddingLeft: "30px", marginBottom: "2rem", flex: "1 1 auto" }}
|
||||
>
|
||||
<div>
|
||||
<Divider />
|
||||
</div>
|
||||
<Button
|
||||
style={{ alignSelf: "flex-end" }}
|
||||
variant="outline-danger"
|
||||
onClick={async () => {
|
||||
if (confirm("Are you sure you want to sign out?")) {
|
||||
const rj = await APIFetchV1("/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (rj.success) {
|
||||
toast.success("Logged out.");
|
||||
setTimeout(() => {
|
||||
setUser(null);
|
||||
localStorage.removeItem("isLoggedIn");
|
||||
// This has to be the case.
|
||||
// Otherwise, react just ruins its own
|
||||
// state. I hate react state.
|
||||
window.location.href = "/";
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon type="sign-out-alt" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
import { ToAPIURL } from "util/api";
|
||||
import { UserContext } from "context/UserContext";
|
||||
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,
|
||||
setMobileShow,
|
||||
}: {
|
||||
mobileShow: boolean;
|
||||
setMobileShow: SetState<boolean>;
|
||||
}) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="kt_header_mobile" className="header-mobile header-mobile-fixed">
|
||||
<div className="d-flex align-items-center justify-content-start">
|
||||
<button
|
||||
className="btn p-0 burger-icon burger-icon-right ml-2 mr-auto"
|
||||
id="kt_header_mobile_toggle"
|
||||
onClick={() => setMobileShow(!mobileShow)}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center">
|
||||
<SearchButton />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<UserNotificationButton user={user} />
|
||||
<button
|
||||
className="btn btn-icon btn-hover-transparent-white p-0 ml-3"
|
||||
id="kt_header_mobile_topbar_toggle"
|
||||
>
|
||||
<Link to={`/u/${user.username}`}>
|
||||
<div className="px-4">
|
||||
<img
|
||||
className="rounded"
|
||||
src={ToAPIURL("/users/me/pfp")}
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { ToAPIURL } from "util/api";
|
||||
import { UserContext } from "context/UserContext";
|
||||
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,
|
||||
setMobileShow,
|
||||
}: {
|
||||
mobileShow: boolean;
|
||||
setMobileShow: SetState<boolean>;
|
||||
}) {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="kt_header_mobile" className="header-mobile header-mobile-fixed">
|
||||
<div className="d-flex align-items-center justify-content-start">
|
||||
<button
|
||||
className="btn p-0 burger-icon burger-icon-right ml-2 mr-auto"
|
||||
id="kt_header_mobile_toggle"
|
||||
onClick={() => setMobileShow(!mobileShow)}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center">
|
||||
<SearchButton />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<UserNotificationButton user={user} />
|
||||
<button
|
||||
className="btn btn-icon btn-hover-transparent-white p-0 ml-3"
|
||||
id="kt_header_mobile_topbar_toggle"
|
||||
>
|
||||
<Link to={`/u/${user.username}`}>
|
||||
<div className="px-4">
|
||||
<img
|
||||
className="rounded"
|
||||
src={ToAPIURL("/users/me/pfp")}
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { UserProfileDropdown } from "components/layout/header/UserProfileDropdown";
|
||||
import LinkButton from "components/util/LinkButton";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext } from "react";
|
||||
import { UserNotificationButton } from "./UserNotificationButton";
|
||||
import { SearchButton } from "./SearchButton";
|
||||
|
||||
export function Topbar() {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<div className="topbar" style={{ minWidth: "35%" }}>
|
||||
<SearchButton />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<UserNotificationButton user={user} />
|
||||
<UserProfileDropdown user={user} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="topbar-item mr-3">
|
||||
<LinkButton to="/login" className="btn-outline-primary">
|
||||
Log In
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="topbar-item">
|
||||
<LinkButton to="/register">Create Account</LinkButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { UserProfileDropdown } from "components/layout/header/UserProfileDropdown";
|
||||
import LinkButton from "components/util/LinkButton";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext } from "react";
|
||||
import { UserNotificationButton } from "./UserNotificationButton";
|
||||
import { SearchButton } from "./SearchButton";
|
||||
|
||||
export function Topbar() {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<div className="topbar" style={{ minWidth: "35%" }}>
|
||||
<SearchButton />
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<UserNotificationButton user={user} />
|
||||
<UserProfileDropdown user={user} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="topbar-item mr-3">
|
||||
<LinkButton to="/login" className="btn-outline-primary">
|
||||
Log In
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="topbar-item">
|
||||
<LinkButton to="/register">Create Account</LinkButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
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 UserNotificationButton({ user }: { user: UserDocument }) {
|
||||
const { notifications } = useContext(NotificationsContext);
|
||||
|
||||
const unread = useMemo(() => {
|
||||
if (!notifications) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return notifications.filter((e) => e.read === false).length;
|
||||
}, [notifications]);
|
||||
|
||||
return (
|
||||
<div className="topbar-item">
|
||||
<Link to="/notifications">
|
||||
<div
|
||||
className="btn btn-icon btn-hover-transparent-white btn-dropdown btn-lg mr-1"
|
||||
id="kt_quick_notifications_toggle"
|
||||
>
|
||||
{unread > 0 ? (
|
||||
<Icon type="envelope" colour="primary" />
|
||||
) : (
|
||||
<Icon type="envelope" regular colour="muted" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 UserNotificationButton({ user }: { user: UserDocument }) {
|
||||
const { notifications } = useContext(NotificationsContext);
|
||||
|
||||
const unread = useMemo(() => {
|
||||
if (!notifications) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return notifications.filter((e) => e.read === false).length;
|
||||
}, [notifications]);
|
||||
|
||||
return (
|
||||
<div className="topbar-item">
|
||||
<Link to="/notifications">
|
||||
<div
|
||||
className="btn btn-icon btn-hover-transparent-white btn-dropdown btn-lg mr-1"
|
||||
id="kt_quick_notifications_toggle"
|
||||
>
|
||||
{unread > 0 ? (
|
||||
<Icon type="envelope" colour="primary" />
|
||||
) : (
|
||||
<Icon type="envelope" regular colour="muted" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,135 +1,135 @@
|
||||
import { APIFetchV1, ToAPIURL } from "util/api";
|
||||
import { RFA } from "util/misc";
|
||||
import { heySplashes } from "util/splashes";
|
||||
import Divider from "components/util/Divider";
|
||||
import DropdownToggleOverride from "components/util/DropdownToggleOverride";
|
||||
import Icon from "components/util/Icon";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import Dropdown from "react-bootstrap/Dropdown";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
import { UserDocument } from "tachi-common";
|
||||
|
||||
export function UserProfileDropdown({ user }: { user: UserDocument }) {
|
||||
const { setUser } = useContext(UserContext);
|
||||
const [heySplash] = useState(RFA(heySplashes));
|
||||
|
||||
return (
|
||||
<Dropdown drop="down" alignRight>
|
||||
<Dropdown.Toggle as={DropdownToggleOverride} id="dropdown-toggle-user-profile">
|
||||
<div
|
||||
className={
|
||||
"btn btn-icon btn-hover-transparent-white d-flex align-items-center btn-lg px-md-2 w-md-auto"
|
||||
}
|
||||
>
|
||||
<span className="text-white opacity-70 font-weight-bold font-size-base d-none d-md-inline mr-1">
|
||||
{heySplash},
|
||||
</span>{" "}
|
||||
<span className="text-white opacity-90 font-weight-bolder font-size-base d-none d-md-inline mr-2">
|
||||
{user.username}
|
||||
</span>
|
||||
<span className="symbol symbol-35">
|
||||
<img alt="Pic" className="hidden" src={ToAPIURL("/users/me/pfp")} />
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="p-0 m-0 dropdown-menu-right dropdown-menu-anim dropdown-menu-top-unround dropdown-menu-xl">
|
||||
<div className="navi navi-spacer-x-0 pt-5">
|
||||
<Link
|
||||
to={`/u/${user.username}`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
>
|
||||
<div className="navi-link">
|
||||
<div className="navi-icon mr-2">
|
||||
<Icon type="user" colour="primary" />
|
||||
</div>
|
||||
<div className="navi-text">
|
||||
<div className="font-weight-bold cursor-pointer">My Profile</div>
|
||||
<div className="text-muted">View your profile!</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/u/${user.username}`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
>
|
||||
<div className="navi-link">
|
||||
<div className="navi-icon mr-2">
|
||||
<Icon type="envelope" colour="success" />
|
||||
</div>
|
||||
<div className="navi-text">
|
||||
<div className="font-weight-bold cursor-pointer">Notifications</div>
|
||||
<div className="text-muted">View your notifications!</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/u/${user.username}/settings`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
>
|
||||
<div className="navi-link">
|
||||
<div className="navi-icon mr-2">
|
||||
<Icon type="cog" colour="info" />
|
||||
</div>
|
||||
<div className="navi-text">
|
||||
<div className="font-weight-bold cursor-pointer">
|
||||
Profile Settings
|
||||
</div>
|
||||
<div className="text-muted">
|
||||
Manage your profile picture, status, and more!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/u/${user.username}/integrations`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
>
|
||||
<div className="navi-link">
|
||||
<div className="navi-icon mr-2">
|
||||
<Icon type="wrench" colour="danger" />
|
||||
</div>
|
||||
<div className="navi-text">
|
||||
<div className="font-weight-bold cursor-pointer">
|
||||
My Integrations
|
||||
</div>
|
||||
<div className="text-muted">
|
||||
Manage your API Keys and integrations with other services.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="navi-footer px-8 py-5 justify-content-end">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const rj = await APIFetchV1("/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (rj.success) {
|
||||
toast.success("Logged out.");
|
||||
setTimeout(() => {
|
||||
setUser(null);
|
||||
localStorage.removeItem("isLoggedIn");
|
||||
// This has to be the case.
|
||||
// Otherwise, react just ruins its own
|
||||
// state. I hate react state.
|
||||
window.location.href = "/";
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
className="btn-outline-danger font-weight-bold"
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
import { APIFetchV1, ToAPIURL } from "util/api";
|
||||
import { RFA } from "util/misc";
|
||||
import { heySplashes } from "util/splashes";
|
||||
import Divider from "components/util/Divider";
|
||||
import DropdownToggleOverride from "components/util/DropdownToggleOverride";
|
||||
import Icon from "components/util/Icon";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useState } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import Dropdown from "react-bootstrap/Dropdown";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
import { UserDocument } from "tachi-common";
|
||||
|
||||
export function UserProfileDropdown({ user }: { user: UserDocument }) {
|
||||
const { setUser } = useContext(UserContext);
|
||||
const [heySplash] = useState(RFA(heySplashes));
|
||||
|
||||
return (
|
||||
<Dropdown drop="down" alignRight>
|
||||
<Dropdown.Toggle as={DropdownToggleOverride} id="dropdown-toggle-user-profile">
|
||||
<div
|
||||
className={
|
||||
"btn btn-icon btn-hover-transparent-white d-flex align-items-center btn-lg px-md-2 w-md-auto"
|
||||
}
|
||||
>
|
||||
<span className="text-white opacity-70 font-weight-bold font-size-base d-none d-md-inline mr-1">
|
||||
{heySplash},
|
||||
</span>{" "}
|
||||
<span className="text-white opacity-90 font-weight-bolder font-size-base d-none d-md-inline mr-2">
|
||||
{user.username}
|
||||
</span>
|
||||
<span className="symbol symbol-35">
|
||||
<img alt="Pic" className="hidden" src={ToAPIURL("/users/me/pfp")} />
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="p-0 m-0 dropdown-menu-right dropdown-menu-anim dropdown-menu-top-unround dropdown-menu-xl">
|
||||
<div className="navi navi-spacer-x-0 pt-5">
|
||||
<Link
|
||||
to={`/u/${user.username}`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
>
|
||||
<div className="navi-link">
|
||||
<div className="navi-icon mr-2">
|
||||
<Icon type="user" colour="primary" />
|
||||
</div>
|
||||
<div className="navi-text">
|
||||
<div className="font-weight-bold cursor-pointer">My Profile</div>
|
||||
<div className="text-muted">View your profile!</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/u/${user.username}`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
>
|
||||
<div className="navi-link">
|
||||
<div className="navi-icon mr-2">
|
||||
<Icon type="envelope" colour="success" />
|
||||
</div>
|
||||
<div className="navi-text">
|
||||
<div className="font-weight-bold cursor-pointer">Notifications</div>
|
||||
<div className="text-muted">View your notifications!</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/u/${user.username}/settings`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
>
|
||||
<div className="navi-link">
|
||||
<div className="navi-icon mr-2">
|
||||
<Icon type="cog" colour="info" />
|
||||
</div>
|
||||
<div className="navi-text">
|
||||
<div className="font-weight-bold cursor-pointer">
|
||||
Profile Settings
|
||||
</div>
|
||||
<div className="text-muted">
|
||||
Manage your profile picture, status, and more!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/u/${user.username}/integrations`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
>
|
||||
<div className="navi-link">
|
||||
<div className="navi-icon mr-2">
|
||||
<Icon type="wrench" colour="danger" />
|
||||
</div>
|
||||
<div className="navi-text">
|
||||
<div className="font-weight-bold cursor-pointer">
|
||||
My Integrations
|
||||
</div>
|
||||
<div className="text-muted">
|
||||
Manage your API Keys and integrations with other services.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="navi-footer px-8 py-5 justify-content-end">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const rj = await APIFetchV1("/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (rj.success) {
|
||||
toast.success("Logged out.");
|
||||
setTimeout(() => {
|
||||
setUser(null);
|
||||
localStorage.removeItem("isLoggedIn");
|
||||
// This has to be the case.
|
||||
// Otherwise, react just ruins its own
|
||||
// state. I hate react state.
|
||||
window.location.href = "/";
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
className="btn-outline-danger font-weight-bold"
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
|
||||
import { ColourConfig } from "lib/config";
|
||||
import React from "react";
|
||||
import { JustChildren } from "types/react";
|
||||
|
||||
const theme = createTheme({
|
||||
typography: {
|
||||
fontFamily: "Poppins",
|
||||
},
|
||||
palette: {
|
||||
secondary: {
|
||||
main: ColourConfig.primary,
|
||||
},
|
||||
primary: {
|
||||
main: ColourConfig.primary,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiButtonBase: {
|
||||
defaultProps: {
|
||||
disableRipple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function MaterialThemeProvider({ children }: JustChildren) {
|
||||
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
|
||||
}
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
|
||||
import { ColourConfig } from "lib/config";
|
||||
import React from "react";
|
||||
import { JustChildren } from "types/react";
|
||||
|
||||
const theme = createTheme({
|
||||
typography: {
|
||||
fontFamily: "Poppins",
|
||||
},
|
||||
palette: {
|
||||
secondary: {
|
||||
main: ColourConfig.primary,
|
||||
},
|
||||
primary: {
|
||||
main: ColourConfig.primary,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiButtonBase: {
|
||||
defaultProps: {
|
||||
disableRipple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function MaterialThemeProvider({ children }: JustChildren) {
|
||||
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { UserDocument } from "tachi-common";
|
||||
import { JustChildren } from "types/react";
|
||||
import { SplashScreen } from "./SplashScreen";
|
||||
|
||||
export function LoadingScreen({ children }: JustChildren) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
// if we've been waiting around for too long, maybe we're down.
|
||||
const [broke, setBroke] = useState("");
|
||||
|
||||
const { setUser } = useContext(UserContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem("isLoggedIn")) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
setBroke(
|
||||
`It's taking a long time for ${TachiConfig.name} to load. We might be under strain, or completely down, sorry!`
|
||||
),
|
||||
4_000
|
||||
);
|
||||
|
||||
Promise.all([
|
||||
APIFetchV1<UserDocument>("/users/me").then((rj) => {
|
||||
if (rj.success) {
|
||||
setUser(rj.body);
|
||||
} else {
|
||||
localStorage.removeItem("isLoggedIn");
|
||||
}
|
||||
}),
|
||||
])
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
clearTimeout(timeout);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setBroke(
|
||||
`${TachiConfig.name} is currently down, sadly. Check back in a while, or we might be doing a quick server restart. Sorry!`
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <SplashScreen broke={broke} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import { TachiConfig } from "lib/config";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { UserDocument } from "tachi-common";
|
||||
import { JustChildren } from "types/react";
|
||||
import { SplashScreen } from "./SplashScreen";
|
||||
|
||||
export function LoadingScreen({ children }: JustChildren) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
// if we've been waiting around for too long, maybe we're down.
|
||||
const [broke, setBroke] = useState("");
|
||||
|
||||
const { setUser } = useContext(UserContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem("isLoggedIn")) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
setBroke(
|
||||
`It's taking a long time for ${TachiConfig.name} to load. We might be under strain, or completely down, sorry!`
|
||||
),
|
||||
4_000
|
||||
);
|
||||
|
||||
Promise.all([
|
||||
APIFetchV1<UserDocument>("/users/me").then((rj) => {
|
||||
if (rj.success) {
|
||||
setUser(rj.body);
|
||||
} else {
|
||||
localStorage.removeItem("isLoggedIn");
|
||||
}
|
||||
}),
|
||||
])
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
clearTimeout(timeout);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setBroke(
|
||||
`${TachiConfig.name} is currently down, sadly. Check back in a while, or we might be doing a quick server restart. Sorry!`
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <SplashScreen broke={broke} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { CircularProgress } from "@mui/material";
|
||||
import React from "react";
|
||||
import SplashImage from "../misc/SplashImage";
|
||||
|
||||
export function SplashScreen({ broke }: { broke: string }) {
|
||||
return (
|
||||
<div id="splash-screen" className="splash-screen">
|
||||
<SplashImage />
|
||||
{!broke && <CircularProgress className="splash-screen-spinner" />}
|
||||
{broke && <p className="mt-4">{broke}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { CircularProgress } from "@mui/material";
|
||||
import React from "react";
|
||||
import SplashImage from "../misc/SplashImage";
|
||||
|
||||
export function SplashScreen({ broke }: { broke: string }) {
|
||||
return (
|
||||
<div id="splash-screen" className="splash-screen">
|
||||
<SplashImage />
|
||||
{!broke && <CircularProgress className="splash-screen-spinner" />}
|
||||
{broke && <p className="mt-4">{broke}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import Divider from "components/util/Divider";
|
||||
import React, { useContext } from "react";
|
||||
import { SubheaderContext } from "../../../context/SubheaderContext";
|
||||
import { Breadcrumbs } from "./components/Breadcrumbs";
|
||||
|
||||
export function SubHeader() {
|
||||
const subheader = useContext(SubheaderContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="kt_subheader" className="subheader">
|
||||
<div className="container d-flex align-items-center justify-content-start">
|
||||
<div className="d-flex w-100 d-md-none align-items-center justify-content-center">
|
||||
<h2 className="text-white font-weight-bold">{subheader.title}</h2>
|
||||
</div>
|
||||
<div className="d-none d-md-flex flex-wrap mr-1 justify-content-start">
|
||||
<div className="d-flex flex-column">
|
||||
<h2 className="text-white font-weight-bold my-2 mr-5">
|
||||
{subheader.title}
|
||||
</h2>
|
||||
|
||||
<Breadcrumbs items={subheader.breadcrumbs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container d-flex mb-8 mt-8 w-100">
|
||||
<Divider className="w-100" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import Divider from "components/util/Divider";
|
||||
import React, { useContext } from "react";
|
||||
import { SubheaderContext } from "../../../context/SubheaderContext";
|
||||
import { Breadcrumbs } from "./components/Breadcrumbs";
|
||||
|
||||
export function SubHeader() {
|
||||
const subheader = useContext(SubheaderContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="kt_subheader" className="subheader">
|
||||
<div className="container d-flex align-items-center justify-content-start">
|
||||
<div className="d-flex w-100 d-md-none align-items-center justify-content-center">
|
||||
<h2 className="text-white font-weight-bold">{subheader.title}</h2>
|
||||
</div>
|
||||
<div className="d-none d-md-flex flex-wrap mr-1 justify-content-start">
|
||||
<div className="d-flex flex-column">
|
||||
<h2 className="text-white font-weight-bold my-2 mr-5">
|
||||
{subheader.title}
|
||||
</h2>
|
||||
|
||||
<Breadcrumbs items={subheader.breadcrumbs} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container d-flex mb-8 mt-8 w-100">
|
||||
<Divider className="w-100" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import React, { Fragment } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { GetGameConfig } from "tachi-common";
|
||||
|
||||
export function Breadcrumbs({ items }: { items: string[] }) {
|
||||
const url = location.pathname;
|
||||
|
||||
// we also need to remove the first param, as it is an empty string
|
||||
const parts = url.split("/").slice(1);
|
||||
|
||||
return (
|
||||
<div className="align-items-center font-weight-bold my-2 d-flex">
|
||||
<Link className="opacity-75 hover-opacity-100" to="/">
|
||||
<i className="flaticon2-shelter text-white icon-1x" />
|
||||
</Link>
|
||||
{items.map((name, index) => {
|
||||
// Skip playtype in the breadcrumbs if the game only has one playtype.
|
||||
// @hack
|
||||
// this only works if the game has one playtype called "Single".
|
||||
// Some games (like pop'n) have one playtype, but it's called
|
||||
// 9B. They'll just have to cope.
|
||||
if (items[index - 2] === "Games" && name === "Single") {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<span className="label label-dot label-sm bg-white opacity-75 mx-3" />
|
||||
<Link
|
||||
className="text-white text-hover-white opacity-75 hover-opacity-100"
|
||||
to={`/${parts.slice(0, index + 1).join("/")}`}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import React, { Fragment } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { GetGameConfig } from "tachi-common";
|
||||
|
||||
export function Breadcrumbs({ items }: { items: string[] }) {
|
||||
const url = location.pathname;
|
||||
|
||||
// we also need to remove the first param, as it is an empty string
|
||||
const parts = url.split("/").slice(1);
|
||||
|
||||
return (
|
||||
<div className="align-items-center font-weight-bold my-2 d-flex">
|
||||
<Link className="opacity-75 hover-opacity-100" to="/">
|
||||
<i className="flaticon2-shelter text-white icon-1x" />
|
||||
</Link>
|
||||
{items.map((name, index) => {
|
||||
// Skip playtype in the breadcrumbs if the game only has one playtype.
|
||||
// @hack
|
||||
// this only works if the game has one playtype called "Single".
|
||||
// Some games (like pop'n) have one playtype, but it's called
|
||||
// 9B. They'll just have to cope.
|
||||
if (items[index - 2] === "Games" && name === "Single") {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<span className="label label-dot label-sm bg-white opacity-75 mx-3" />
|
||||
<Link
|
||||
className="text-white text-hover-white opacity-75 hover-opacity-100"
|
||||
to={`/${parts.slice(0, index + 1).join("/")}`}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { NO_OP } from "util/misc";
|
||||
import React, { createContext, useState } from "react";
|
||||
import { JustChildren, SetState } from "types/react";
|
||||
|
||||
export const SubheaderContext = createContext({
|
||||
title: "",
|
||||
setTitle: NO_OP as SetState<string>,
|
||||
breadcrumbs: [] as string[],
|
||||
setBreadcrumbs: NO_OP as SetState<string[]>,
|
||||
});
|
||||
|
||||
export function SubheaderContextProvider({ children }: JustChildren) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
|
||||
return (
|
||||
<SubheaderContext.Provider value={{ title, setTitle, breadcrumbs, setBreadcrumbs }}>
|
||||
{children}
|
||||
</SubheaderContext.Provider>
|
||||
);
|
||||
}
|
||||
import { NO_OP } from "util/misc";
|
||||
import React, { createContext, useState } from "react";
|
||||
import { JustChildren, SetState } from "types/react";
|
||||
|
||||
export const SubheaderContext = createContext({
|
||||
title: "",
|
||||
setTitle: NO_OP as SetState<string>,
|
||||
breadcrumbs: [] as string[],
|
||||
setBreadcrumbs: NO_OP as SetState<string[]>,
|
||||
});
|
||||
|
||||
export function SubheaderContextProvider({ children }: JustChildren) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
|
||||
return (
|
||||
<SubheaderContext.Provider value={{ title, setTitle, breadcrumbs, setBreadcrumbs }}>
|
||||
{children}
|
||||
</SubheaderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-12
@@ -1,12 +1,12 @@
|
||||
import "@fortawesome/fontawesome-free/css/all.min.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import "./_style/stub.scss";
|
||||
import "./_assets/flaticon/flaticon.css";
|
||||
import "./_assets/flaticon2/flaticon.css";
|
||||
import "./_assets/keenthemes-icons/font/ki.css";
|
||||
import App from "app/App";
|
||||
|
||||
const { PUBLIC_URL } = process.env;
|
||||
|
||||
ReactDOM.render(<App basename={PUBLIC_URL!} />, document.getElementById("root"));
|
||||
import "@fortawesome/fontawesome-free/css/all.min.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import "./_style/stub.scss";
|
||||
import "./_assets/flaticon/flaticon.css";
|
||||
import "./_assets/flaticon2/flaticon.css";
|
||||
import "./_assets/keenthemes-icons/font/ki.css";
|
||||
import App from "app/App";
|
||||
|
||||
const { PUBLIC_URL } = process.env;
|
||||
|
||||
ReactDOM.render(<App basename={PUBLIC_URL!} />, document.getElementById("root"));
|
||||
|
||||
Reference in New Issue
Block a user