mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Consistent actions/loaders folder structure
This commit is contained in:
parent
c0ec15b7de
commit
65c8cfc5ef
|
|
@ -4,7 +4,7 @@ import clsx from "clsx";
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebounce } from "react-use";
|
||||
import type { UserSearchLoaderData } from "~/features/user-search/routes/u";
|
||||
import type { UserSearchLoaderData } from "~/features/user-search/loaders/u.server";
|
||||
import { Avatar } from "./Avatar";
|
||||
|
||||
type UserSearchUserItem = NonNullable<UserSearchLoaderData>["users"][number];
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ import { UserSearch } from "~/components/UserSearch";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
|
||||
import { isAdmin, isMod } from "~/permissions";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
|
||||
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { action } from "../actions/admin.server";
|
||||
import { loader } from "../loaders/admin.server";
|
||||
export { action, loader };
|
||||
export { loader, action };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
|
|
|
|||
112
app/features/art/actions/art.new.server.ts
Normal file
112
app/features/art/actions/art.new.server.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import {
|
||||
unstable_composeUploadHandlers as composeUploadHandlers,
|
||||
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
|
||||
unstable_parseMultipartFormData as parseMultipartFormData,
|
||||
redirect,
|
||||
} from "@remix-run/node";
|
||||
import { nanoid } from "nanoid";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { s3UploadHandler } from "~/features/img-upload";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
parseFormData,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { userArtPage } from "~/utils/urls";
|
||||
import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants";
|
||||
import { editArtSchema, newArtSchema } from "../art-schemas.server";
|
||||
import { addNewArt, editArt } from "../queries/addNewArt.server";
|
||||
import { findArtById } from "../queries/findArtById.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
errorToastIfFalsy(user.isArtist, "Lacking artist role");
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY);
|
||||
|
||||
// updating logic
|
||||
if (artIdRaw) {
|
||||
const artId = Number(artIdRaw);
|
||||
|
||||
const existingArt = findArtById(artId);
|
||||
errorToastIfFalsy(
|
||||
existingArt?.authorId === user.id,
|
||||
"Art author is someone else",
|
||||
);
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: editArtSchema,
|
||||
});
|
||||
|
||||
const editedArtId = editArt({
|
||||
authorId: user.id,
|
||||
artId,
|
||||
description: data.description,
|
||||
isShowcase: data.isShowcase,
|
||||
linkedUsers: data.linkedUsers,
|
||||
tags: data.tags,
|
||||
});
|
||||
|
||||
const newLinkedUsers = data.linkedUsers.filter(
|
||||
(userId) => !existingArt.linkedUsers.includes(userId),
|
||||
);
|
||||
|
||||
notify({
|
||||
userIds: newLinkedUsers,
|
||||
notification: {
|
||||
type: "TAGGED_TO_ART",
|
||||
meta: {
|
||||
adderUsername: user.username,
|
||||
adderDiscordId: user.discordId,
|
||||
artId: editedArtId,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const uploadHandler = composeUploadHandlers(
|
||||
s3UploadHandler(`art-${nanoid()}-${Date.now()}`),
|
||||
createMemoryUploadHandler(),
|
||||
);
|
||||
const formData = await parseMultipartFormData(request, uploadHandler);
|
||||
const imgSrc = formData.get("img") as string | null;
|
||||
invariant(imgSrc);
|
||||
|
||||
const urlParts = imgSrc.split("/");
|
||||
const fileName = urlParts[urlParts.length - 1];
|
||||
invariant(fileName);
|
||||
|
||||
const data = await parseFormData({
|
||||
formData,
|
||||
schema: newArtSchema,
|
||||
});
|
||||
|
||||
const addedArtId = addNewArt({
|
||||
authorId: user.id,
|
||||
description: data.description,
|
||||
url: fileName,
|
||||
validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null,
|
||||
linkedUsers: data.linkedUsers,
|
||||
tags: data.tags,
|
||||
});
|
||||
|
||||
notify({
|
||||
userIds: data.linkedUsers,
|
||||
notification: {
|
||||
type: "TAGGED_TO_ART",
|
||||
meta: {
|
||||
adderUsername: user.username,
|
||||
adderDiscordId: user.discordId,
|
||||
artId: addedArtId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(userArtPage(user));
|
||||
};
|
||||
|
|
@ -11,3 +11,4 @@ export const ART = {
|
|||
};
|
||||
|
||||
export const NEW_ART_EXISTING_SEARCH_PARAM_KEY = "art";
|
||||
export const FILTERED_TAG_KEY_SEARCH_PARAM_KEY = "tag";
|
||||
|
|
|
|||
24
app/features/art/loaders/art.new.server.ts
Normal file
24
app/features/art/loaders/art.new.server.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { unauthorizedIfFalsy } from "~/utils/remix.server";
|
||||
import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants";
|
||||
import { allArtTags } from "../queries/allArtTags.server";
|
||||
import { findArtById } from "../queries/findArtById.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
unauthorizedIfFalsy(user.isArtist);
|
||||
|
||||
const artIdRaw = new URL(request.url).searchParams.get(
|
||||
NEW_ART_EXISTING_SEARCH_PARAM_KEY,
|
||||
);
|
||||
if (!artIdRaw) return { art: null, tags: allArtTags() };
|
||||
const artId = Number(artIdRaw);
|
||||
|
||||
const art = findArtById(artId);
|
||||
if (!art || art.authorId !== user.id) {
|
||||
return { art: null, tags: allArtTags() };
|
||||
}
|
||||
|
||||
return { art, tags: allArtTags() };
|
||||
};
|
||||
21
app/features/art/loaders/art.server.ts
Normal file
21
app/features/art/loaders/art.server.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants";
|
||||
import { allArtTags } from "../queries/allArtTags.server";
|
||||
import {
|
||||
showcaseArts,
|
||||
showcaseArtsByTag,
|
||||
} from "../queries/showcaseArts.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const allTags = allArtTags();
|
||||
|
||||
const filteredTagName = new URL(request.url).searchParams.get(
|
||||
FILTERED_TAG_KEY_SEARCH_PARAM_KEY,
|
||||
);
|
||||
const filteredTag = allTags.find((t) => t.name === filteredTagName);
|
||||
|
||||
return {
|
||||
arts: filteredTag ? showcaseArtsByTag(filteredTag.id) : showcaseArts(),
|
||||
allTags,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,14 +1,4 @@
|
|||
import type {
|
||||
ActionFunction,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import {
|
||||
unstable_composeUploadHandlers as composeUploadHandlers,
|
||||
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
|
||||
unstable_parseMultipartFormData as parseMultipartFormData,
|
||||
redirect,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { Form, useLoaderData } from "@remix-run/react";
|
||||
import Compressor from "compressorjs";
|
||||
import { nanoid } from "nanoid";
|
||||
|
|
@ -25,31 +15,20 @@ import { UserSearch } from "~/components/UserSearch";
|
|||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { s3UploadHandler } from "~/features/img-upload";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
errorToastIfFalsy,
|
||||
parseFormData,
|
||||
parseRequestPayload,
|
||||
unauthorizedIfFalsy,
|
||||
} from "~/utils/remix.server";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
artPage,
|
||||
conditionalUserSubmittedImage,
|
||||
navIconUrl,
|
||||
userArtPage,
|
||||
} from "~/utils/urls";
|
||||
import { metaTitle } from "../../../utils/remix";
|
||||
import { ART, NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants";
|
||||
import { editArtSchema, newArtSchema } from "../art-schemas.server";
|
||||
import { ART } from "../art-constants";
|
||||
import { previewUrl } from "../art-utils";
|
||||
import { addNewArt, editArt } from "../queries/addNewArt.server";
|
||||
import { allArtTags } from "../queries/allArtTags.server";
|
||||
import { findArtById } from "../queries/findArtById.server";
|
||||
|
||||
import { action } from "../actions/art.new.server";
|
||||
import { loader } from "../loaders/art.new.server";
|
||||
export { loader, action };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["art"],
|
||||
|
|
@ -66,113 +45,6 @@ export const meta: MetaFunction = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
errorToastIfFalsy(user.isArtist, "Lacking artist role");
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY);
|
||||
|
||||
// updating logic
|
||||
if (artIdRaw) {
|
||||
const artId = Number(artIdRaw);
|
||||
|
||||
const existingArt = findArtById(artId);
|
||||
errorToastIfFalsy(
|
||||
existingArt?.authorId === user.id,
|
||||
"Art author is someone else",
|
||||
);
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: editArtSchema,
|
||||
});
|
||||
|
||||
const editedArtId = editArt({
|
||||
authorId: user.id,
|
||||
artId,
|
||||
description: data.description,
|
||||
isShowcase: data.isShowcase,
|
||||
linkedUsers: data.linkedUsers,
|
||||
tags: data.tags,
|
||||
});
|
||||
|
||||
const newLinkedUsers = data.linkedUsers.filter(
|
||||
(userId) => !existingArt.linkedUsers.includes(userId),
|
||||
);
|
||||
|
||||
notify({
|
||||
userIds: newLinkedUsers,
|
||||
notification: {
|
||||
type: "TAGGED_TO_ART",
|
||||
meta: {
|
||||
adderUsername: user.username,
|
||||
adderDiscordId: user.discordId,
|
||||
artId: editedArtId,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const uploadHandler = composeUploadHandlers(
|
||||
s3UploadHandler(`art-${nanoid()}-${Date.now()}`),
|
||||
createMemoryUploadHandler(),
|
||||
);
|
||||
const formData = await parseMultipartFormData(request, uploadHandler);
|
||||
const imgSrc = formData.get("img") as string | null;
|
||||
invariant(imgSrc);
|
||||
|
||||
const urlParts = imgSrc.split("/");
|
||||
const fileName = urlParts[urlParts.length - 1];
|
||||
invariant(fileName);
|
||||
|
||||
const data = await parseFormData({
|
||||
formData,
|
||||
schema: newArtSchema,
|
||||
});
|
||||
|
||||
const addedArtId = addNewArt({
|
||||
authorId: user.id,
|
||||
description: data.description,
|
||||
url: fileName,
|
||||
validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null,
|
||||
linkedUsers: data.linkedUsers,
|
||||
tags: data.tags,
|
||||
});
|
||||
|
||||
notify({
|
||||
userIds: data.linkedUsers,
|
||||
notification: {
|
||||
type: "TAGGED_TO_ART",
|
||||
meta: {
|
||||
adderUsername: user.username,
|
||||
adderDiscordId: user.discordId,
|
||||
artId: addedArtId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(userArtPage(user));
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
unauthorizedIfFalsy(user.isArtist);
|
||||
|
||||
const artIdRaw = new URL(request.url).searchParams.get(
|
||||
NEW_ART_EXISTING_SEARCH_PARAM_KEY,
|
||||
);
|
||||
if (!artIdRaw) return { art: null, tags: allArtTags() };
|
||||
const artId = Number(artIdRaw);
|
||||
|
||||
const art = findArtById(artId);
|
||||
if (!art || art.authorId !== user.id) {
|
||||
return { art: null, tags: allArtTags() };
|
||||
}
|
||||
|
||||
return { art, tags: allArtTags() };
|
||||
};
|
||||
|
||||
export default function NewArtPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [img, setImg] = React.useState<File | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import type {
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import type { ShouldRevalidateFunction } from "@remix-run/react";
|
||||
import { useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -15,19 +11,21 @@ import { CrossIcon } from "~/components/icons/Cross";
|
|||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { artPage, navIconUrl } from "~/utils/urls";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants";
|
||||
import { ArtGrid } from "../components/ArtGrid";
|
||||
import { allArtTags } from "../queries/allArtTags.server";
|
||||
import {
|
||||
showcaseArts,
|
||||
showcaseArtsByTag,
|
||||
} from "../queries/showcaseArts.server";
|
||||
|
||||
const FILTERED_TAG_KEY = "tag";
|
||||
import { loader } from "../loaders/art.server";
|
||||
export { loader };
|
||||
|
||||
const OPEN_COMMISIONS_KEY = "open";
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
|
||||
const currentFilteredTag = args.currentUrl.searchParams.get(FILTERED_TAG_KEY);
|
||||
const nextFilteredTag = args.nextUrl.searchParams.get(FILTERED_TAG_KEY);
|
||||
const currentFilteredTag = args.currentUrl.searchParams.get(
|
||||
FILTERED_TAG_KEY_SEARCH_PARAM_KEY,
|
||||
);
|
||||
const nextFilteredTag = args.nextUrl.searchParams.get(
|
||||
FILTERED_TAG_KEY_SEARCH_PARAM_KEY,
|
||||
);
|
||||
|
||||
if (currentFilteredTag === nextFilteredTag) return false;
|
||||
|
||||
|
|
@ -57,26 +55,12 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const allTags = allArtTags();
|
||||
|
||||
const filteredTagName = new URL(request.url).searchParams.get(
|
||||
FILTERED_TAG_KEY,
|
||||
);
|
||||
const filteredTag = allTags.find((t) => t.name === filteredTagName);
|
||||
|
||||
return {
|
||||
arts: filteredTag ? showcaseArtsByTag(filteredTag.id) : showcaseArts(),
|
||||
allTags,
|
||||
};
|
||||
};
|
||||
|
||||
export default function ArtPage() {
|
||||
const { t } = useTranslation(["art", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const filteredTag = searchParams.get(FILTERED_TAG_KEY);
|
||||
const filteredTag = searchParams.get(FILTERED_TAG_KEY_SEARCH_PARAM_KEY);
|
||||
const showOpenCommissions = searchParams.get(OPEN_COMMISIONS_KEY) === "true";
|
||||
|
||||
const arts = !showOpenCommissions
|
||||
|
|
@ -114,7 +98,7 @@ export default function ArtPage() {
|
|||
if (!selection) return;
|
||||
|
||||
setSearchParams((prev) => {
|
||||
prev.set(FILTERED_TAG_KEY, selection.label);
|
||||
prev.set(FILTERED_TAG_KEY_SEARCH_PARAM_KEY, selection.label);
|
||||
return prev;
|
||||
});
|
||||
}}
|
||||
|
|
@ -129,7 +113,7 @@ export default function ArtPage() {
|
|||
icon={<CrossIcon />}
|
||||
onClick={() => {
|
||||
setSearchParams((prev) => {
|
||||
prev.delete(FILTERED_TAG_KEY);
|
||||
prev.delete(FILTERED_TAG_KEY_SEARCH_PARAM_KEY);
|
||||
return prev;
|
||||
});
|
||||
}}
|
||||
|
|
|
|||
12
app/features/articles/loaders/a.$slug.server.ts
Normal file
12
app/features/articles/loaders/a.$slug.server.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import { articleBySlug } from "../core/bySlug.server";
|
||||
|
||||
export const loader = ({ params }: LoaderFunctionArgs) => {
|
||||
invariant(params.slug);
|
||||
|
||||
const article = notFoundIfFalsy(articleBySlug(params.slug));
|
||||
|
||||
return { ...article, slug: params.slug };
|
||||
};
|
||||
9
app/features/articles/loaders/a.server.ts
Normal file
9
app/features/articles/loaders/a.server.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { mostRecentArticles } from "../core/list.server";
|
||||
|
||||
const MAX_ARTICLES_COUNT = 100;
|
||||
|
||||
export const loader = async () => {
|
||||
return {
|
||||
articles: await mostRecentArticles(MAX_ARTICLES_COUNT),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,15 +1,10 @@
|
|||
import type {
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import Markdown from "markdown-to-jsx";
|
||||
import * as React from "react";
|
||||
import { Main } from "~/components/Main";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import {
|
||||
ARTICLES_MAIN_PAGE,
|
||||
articlePage,
|
||||
|
|
@ -17,7 +12,9 @@ import {
|
|||
navIconUrl,
|
||||
} from "~/utils/urls";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import { articleBySlug } from "../core/bySlug.server";
|
||||
|
||||
import { loader } from "../loaders/a.$slug.server";
|
||||
export { loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
breadcrumb: ({ match }) => {
|
||||
|
|
@ -58,14 +55,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const loader = ({ params }: LoaderFunctionArgs) => {
|
||||
invariant(params.slug);
|
||||
|
||||
const article = notFoundIfFalsy(articleBySlug(params.slug));
|
||||
|
||||
return { ...article, slug: params.slug };
|
||||
};
|
||||
|
||||
export default function ArticlePage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||
import { Main } from "~/components/Main";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { ARTICLES_MAIN_PAGE, articlePage, navIconUrl } from "~/utils/urls";
|
||||
import { joinListToNaturalString } from "../../../utils/arrays";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import { mostRecentArticles } from "../core/list.server";
|
||||
|
||||
import { loader } from "../loaders/a.server";
|
||||
export { loader };
|
||||
|
||||
import "~/styles/front.css";
|
||||
import { joinListToNaturalString } from "../../../utils/arrays";
|
||||
|
||||
const MAX_ARTICLES_COUNT = 100;
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
breadcrumb: () => ({
|
||||
|
|
@ -30,12 +30,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const loader = async () => {
|
||||
return {
|
||||
articles: await mostRecentArticles(MAX_ARTICLES_COUNT),
|
||||
};
|
||||
};
|
||||
|
||||
export default function ArticlesMainPage() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
94
app/features/badges/actions/badges.$id.edit.server.ts
Normal file
94
app/features/badges/actions/badges.$id.edit.server.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { canEditBadgeManagers, canEditBadgeOwners } from "~/permissions";
|
||||
import { diff } from "~/utils/arrays";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { badgePage } from "~/utils/urls";
|
||||
import { actualNumber } from "~/utils/zod";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
import { editBadgeActionSchema } from "../badges-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: editBadgeActionSchema,
|
||||
});
|
||||
const badgeId = z.preprocess(actualNumber, z.number()).parse(params.id);
|
||||
const user = await requireUserId(request);
|
||||
|
||||
const badge = notFoundIfFalsy(await BadgeRepository.findById(badgeId));
|
||||
|
||||
switch (data._action) {
|
||||
case "MANAGERS": {
|
||||
errorToastIfFalsy(
|
||||
canEditBadgeManagers(user),
|
||||
"No permissions to edit managers",
|
||||
);
|
||||
|
||||
const oldManagers = await BadgeRepository.findManagersByBadgeId(badgeId);
|
||||
|
||||
await BadgeRepository.replaceManagers({
|
||||
badgeId,
|
||||
managerIds: data.managerIds,
|
||||
});
|
||||
|
||||
const newManagers = data.managerIds.filter(
|
||||
(newManagerId) =>
|
||||
!oldManagers.some((oldManager) => oldManager.id === newManagerId),
|
||||
);
|
||||
|
||||
notify({
|
||||
userIds: newManagers,
|
||||
notification: {
|
||||
type: "BADGE_MANAGER_ADDED",
|
||||
meta: {
|
||||
badgeId,
|
||||
badgeName: badge.displayName,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "OWNERS": {
|
||||
errorToastIfFalsy(
|
||||
canEditBadgeOwners({
|
||||
user,
|
||||
managers: await BadgeRepository.findManagersByBadgeId(badgeId),
|
||||
}),
|
||||
"No permissions to edit owners",
|
||||
);
|
||||
|
||||
const oldOwners: number[] = (
|
||||
await BadgeRepository.findOwnersByBadgeId(badgeId)
|
||||
).flatMap((owner) => new Array(owner.count).fill(owner.id));
|
||||
|
||||
await BadgeRepository.replaceOwners({ badgeId, ownerIds: data.ownerIds });
|
||||
|
||||
notify({
|
||||
userIds: diff(oldOwners, data.ownerIds),
|
||||
notification: {
|
||||
type: "BADGE_ADDED",
|
||||
meta: {
|
||||
badgeName: badge.displayName,
|
||||
badgeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
throw redirect(badgePage(badgeId));
|
||||
};
|
||||
15
app/features/badges/loaders/badges.$id.server.ts
Normal file
15
app/features/badges/loaders/badges.$id.server.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
|
||||
export type BadgeDetailsLoaderData = SerializeFrom<typeof loader>;
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const badgeId = Number(params.id);
|
||||
if (Number.isNaN(badgeId)) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
return {
|
||||
owners: await BadgeRepository.findOwnersByBadgeId(badgeId),
|
||||
managers: await BadgeRepository.findManagersByBadgeId(badgeId),
|
||||
};
|
||||
};
|
||||
8
app/features/badges/loaders/badges.server.ts
Normal file
8
app/features/badges/loaders/badges.server.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { SerializeFrom } from "@remix-run/node";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
|
||||
export type BadgesLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async () => {
|
||||
return { badges: await BadgeRepository.all() };
|
||||
};
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { Form, useMatches, useOutletContext } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { Label } from "~/components/Label";
|
||||
|
|
@ -10,98 +7,14 @@ import { UserSearch } from "~/components/UserSearch";
|
|||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { canEditBadgeManagers, canEditBadgeOwners } from "~/permissions";
|
||||
import { atOrError, diff } from "~/utils/arrays";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { badgePage } from "~/utils/urls";
|
||||
import { actualNumber } from "~/utils/zod";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
import { editBadgeActionSchema } from "../badges-schemas.server";
|
||||
import type { BadgeDetailsContext, BadgeDetailsLoaderData } from "./badges.$id";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import type * as BadgeRepository from "../BadgeRepository.server";
|
||||
import type { BadgeDetailsLoaderData } from "../loaders/badges.$id.server";
|
||||
import type { BadgeDetailsContext } from "./badges.$id";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: editBadgeActionSchema,
|
||||
});
|
||||
const badgeId = z.preprocess(actualNumber, z.number()).parse(params.id);
|
||||
const user = await requireUserId(request);
|
||||
|
||||
const badge = notFoundIfFalsy(await BadgeRepository.findById(badgeId));
|
||||
|
||||
switch (data._action) {
|
||||
case "MANAGERS": {
|
||||
errorToastIfFalsy(
|
||||
canEditBadgeManagers(user),
|
||||
"No permissions to edit managers",
|
||||
);
|
||||
|
||||
const oldManagers = await BadgeRepository.findManagersByBadgeId(badgeId);
|
||||
|
||||
await BadgeRepository.replaceManagers({
|
||||
badgeId,
|
||||
managerIds: data.managerIds,
|
||||
});
|
||||
|
||||
const newManagers = data.managerIds.filter(
|
||||
(newManagerId) =>
|
||||
!oldManagers.some((oldManager) => oldManager.id === newManagerId),
|
||||
);
|
||||
|
||||
notify({
|
||||
userIds: newManagers,
|
||||
notification: {
|
||||
type: "BADGE_MANAGER_ADDED",
|
||||
meta: {
|
||||
badgeId,
|
||||
badgeName: badge.displayName,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "OWNERS": {
|
||||
errorToastIfFalsy(
|
||||
canEditBadgeOwners({
|
||||
user,
|
||||
managers: await BadgeRepository.findManagersByBadgeId(badgeId),
|
||||
}),
|
||||
"No permissions to edit owners",
|
||||
);
|
||||
|
||||
const oldOwners: number[] = (
|
||||
await BadgeRepository.findOwnersByBadgeId(badgeId)
|
||||
).flatMap((owner) => new Array(owner.count).fill(owner.id));
|
||||
|
||||
await BadgeRepository.replaceOwners({ badgeId, ownerIds: data.ownerIds });
|
||||
|
||||
notify({
|
||||
userIds: diff(oldOwners, data.ownerIds),
|
||||
notification: {
|
||||
type: "BADGE_ADDED",
|
||||
meta: {
|
||||
badgeName: badge.displayName,
|
||||
badgeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
throw redirect(badgePage(badgeId));
|
||||
};
|
||||
import { action } from "../actions/badges.$id.edit.server";
|
||||
export { action };
|
||||
|
||||
export default function EditBadgePage() {
|
||||
const user = useUser();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
|
||||
import { Outlet, useLoaderData, useMatches, useParams } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -8,27 +7,16 @@ import { Redirect } from "~/components/Redirect";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { canEditBadgeOwners, isMod } from "~/permissions";
|
||||
import { BADGES_PAGE } from "~/utils/urls";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
import { badgeExplanationText } from "../badges-utils";
|
||||
import type { BadgesLoaderData } from "./badges";
|
||||
import type { BadgesLoaderData } from "../loaders/badges.server";
|
||||
|
||||
import { loader } from "../loaders/badges.$id.server";
|
||||
export { loader };
|
||||
|
||||
export interface BadgeDetailsContext {
|
||||
badgeName: string;
|
||||
}
|
||||
|
||||
export type BadgeDetailsLoaderData = SerializeFrom<typeof loader>;
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const badgeId = Number(params.id);
|
||||
if (Number.isNaN(badgeId)) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
return {
|
||||
owners: await BadgeRepository.findOwnersByBadgeId(badgeId),
|
||||
managers: await BadgeRepository.findManagersByBadgeId(badgeId),
|
||||
};
|
||||
};
|
||||
|
||||
export default function BadgeDetailsPage() {
|
||||
const user = useUser();
|
||||
const [, parentRoute] = useMatches();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { NavLink, Outlet, useLoaderData } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -11,7 +11,9 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { BADGES_DOC_LINK, BADGES_PAGE, navIconUrl } from "~/utils/urls";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
|
||||
import { type BadgesLoaderData, loader } from "../loaders/badges.server";
|
||||
export { loader };
|
||||
|
||||
import "~/styles/badges.css";
|
||||
|
||||
|
|
@ -34,12 +36,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
export type BadgesLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async () => {
|
||||
return { badges: await BadgeRepository.all() };
|
||||
};
|
||||
|
||||
export default function BadgesPageLayout() {
|
||||
const { t } = useTranslation(["badges"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import { cachified } from "@epic-web/cachified";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { ONE_HOUR_IN_MS } from "~/constants";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { cache, ttl } from "~/utils/cache.server";
|
||||
import { notFoundIfNullLike } from "~/utils/remix.server";
|
||||
import { weaponNameSlugToId } from "~/utils/unslugify.server";
|
||||
import { popularBuilds } from "../build-stats-utils";
|
||||
import { abilitiesByWeaponId } from "../queries/abilitiesByWeaponId.server";
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const t = await i18next.getFixedT(request, ["builds", "weapons"]);
|
||||
const slug = params.slug;
|
||||
const weaponId = notFoundIfNullLike(weaponNameSlugToId(slug));
|
||||
|
||||
const weaponName = t(`weapons:MAIN_${weaponId}`);
|
||||
|
||||
const cachedPopularBuilds = await cachified({
|
||||
key: `popular-builds-${weaponId}`,
|
||||
cache,
|
||||
ttl: ttl(ONE_HOUR_IN_MS),
|
||||
async getFreshValue() {
|
||||
return popularBuilds(abilitiesByWeaponId(weaponId));
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
popularBuilds: cachedPopularBuilds,
|
||||
weaponName,
|
||||
meta: {
|
||||
weaponId,
|
||||
slug: slug!,
|
||||
breadcrumbText: t("builds:linkButton.popularBuilds"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { cachified } from "@epic-web/cachified";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { ONE_HOUR_IN_MS } from "~/constants";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { cache, ttl } from "~/utils/cache.server";
|
||||
import { notFoundIfNullLike } from "~/utils/remix.server";
|
||||
import { weaponNameSlugToId } from "~/utils/unslugify.server";
|
||||
import { abilityPointCountsToAverages } from "../build-stats-utils";
|
||||
import { averageAbilityPoints } from "../queries/averageAbilityPoints.server";
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const t = await i18next.getFixedT(request, ["builds", "weapons"]);
|
||||
const weaponId = notFoundIfNullLike(weaponNameSlugToId(params.slug));
|
||||
|
||||
const weaponName = t(`weapons:MAIN_${weaponId}`);
|
||||
|
||||
const cachedStats = await cachified({
|
||||
key: `build-stats-${weaponId}`,
|
||||
cache,
|
||||
ttl: ttl(ONE_HOUR_IN_MS),
|
||||
async getFreshValue() {
|
||||
return abilityPointCountsToAverages({
|
||||
allAbilities: averageAbilityPoints(),
|
||||
weaponAbilities: averageAbilityPoints(weaponId),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stats: cachedStats,
|
||||
weaponName,
|
||||
weaponId,
|
||||
meta: {
|
||||
slug: params.slug!,
|
||||
breadcrumbText: t("builds:linkButton.abilityStats"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -1,22 +1,10 @@
|
|||
import { cachified } from "@epic-web/cachified";
|
||||
import type {
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ability } from "~/components/Ability";
|
||||
import { Main } from "~/components/Main";
|
||||
import { ONE_HOUR_IN_MS } from "~/constants";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { cache, ttl } from "~/utils/cache.server";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
notFoundIfNullLike,
|
||||
} from "~/utils/remix.server";
|
||||
import { weaponNameSlugToId } from "~/utils/unslugify.server";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
BUILDS_PAGE,
|
||||
navIconUrl,
|
||||
|
|
@ -24,8 +12,9 @@ import {
|
|||
weaponBuildPage,
|
||||
} from "~/utils/urls";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import { popularBuilds } from "../build-stats-utils";
|
||||
import { abilitiesByWeaponId } from "../queries/abilitiesByWeaponId.server";
|
||||
|
||||
import { loader } from "../loaders/builds.$slug.popular.server";
|
||||
export { loader };
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = (args) => {
|
||||
if (!args.data) return [];
|
||||
|
|
@ -65,33 +54,6 @@ export const handle: SendouRouteHandle = {
|
|||
},
|
||||
};
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const t = await i18next.getFixedT(request, ["builds", "weapons"]);
|
||||
const slug = params.slug;
|
||||
const weaponId = notFoundIfNullLike(weaponNameSlugToId(slug));
|
||||
|
||||
const weaponName = t(`weapons:MAIN_${weaponId}`);
|
||||
|
||||
const cachedPopularBuilds = await cachified({
|
||||
key: `popular-builds-${weaponId}`,
|
||||
cache,
|
||||
ttl: ttl(ONE_HOUR_IN_MS),
|
||||
async getFreshValue() {
|
||||
return popularBuilds(abilitiesByWeaponId(weaponId));
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
popularBuilds: cachedPopularBuilds,
|
||||
weaponName,
|
||||
meta: {
|
||||
weaponId,
|
||||
slug: slug!,
|
||||
breadcrumbText: t("builds:linkButton.popularBuilds"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function PopularBuildsPage() {
|
||||
const { t } = useTranslation(["analyzer", "builds"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -1,22 +1,11 @@
|
|||
import { cachified } from "@epic-web/cachified";
|
||||
import type {
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ability } from "~/components/Ability";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { MAX_AP, ONE_HOUR_IN_MS } from "~/constants";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { cache, ttl } from "~/utils/cache.server";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
notFoundIfNullLike,
|
||||
} from "~/utils/remix.server";
|
||||
import { weaponNameSlugToId } from "~/utils/unslugify.server";
|
||||
import { MAX_AP } from "~/constants";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
BUILDS_PAGE,
|
||||
navIconUrl,
|
||||
|
|
@ -24,8 +13,9 @@ import {
|
|||
weaponBuildPage,
|
||||
} from "~/utils/urls";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import { abilityPointCountsToAverages } from "../build-stats-utils";
|
||||
import { averageAbilityPoints } from "../queries/averageAbilityPoints.server";
|
||||
|
||||
import { loader } from "../loaders/builds.$slug.stats.server";
|
||||
export { loader };
|
||||
|
||||
import "../build-stats.css";
|
||||
|
||||
|
|
@ -67,35 +57,6 @@ export const handle: SendouRouteHandle = {
|
|||
},
|
||||
};
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const t = await i18next.getFixedT(request, ["builds", "weapons"]);
|
||||
const weaponId = notFoundIfNullLike(weaponNameSlugToId(params.slug));
|
||||
|
||||
const weaponName = t(`weapons:MAIN_${weaponId}`);
|
||||
|
||||
const cachedStats = await cachified({
|
||||
key: `build-stats-${weaponId}`,
|
||||
cache,
|
||||
ttl: ttl(ONE_HOUR_IN_MS),
|
||||
async getFreshValue() {
|
||||
return abilityPointCountsToAverages({
|
||||
allAbilities: averageAbilityPoints(),
|
||||
weaponAbilities: averageAbilityPoints(weaponId),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
stats: cachedStats,
|
||||
weaponName,
|
||||
weaponId,
|
||||
meta: {
|
||||
slug: params.slug!,
|
||||
breadcrumbText: t("builds:linkButton.abilityStats"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function BuildStatsPage() {
|
||||
const { t } = useTranslation(["weapons", "builds", "analyzer"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { canReportCalendarEventWinners } from "~/permissions";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
safeParseRequestFormData,
|
||||
} from "~/utils/remix.server";
|
||||
import { calendarEventPage } from "~/utils/urls";
|
||||
import {
|
||||
reportWinnersActionSchema,
|
||||
reportWinnersParamsSchema,
|
||||
} from "../calendar-schemas";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUserId(request);
|
||||
const parsedParams = reportWinnersParamsSchema.parse(params);
|
||||
const parsedInput = await safeParseRequestFormData({
|
||||
request,
|
||||
schema: reportWinnersActionSchema,
|
||||
});
|
||||
|
||||
if (!parsedInput.success) {
|
||||
return {
|
||||
errors: parsedInput.errors,
|
||||
};
|
||||
}
|
||||
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({ id: parsedParams.id }),
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes: event.startTimes,
|
||||
}),
|
||||
"Unauthorized",
|
||||
);
|
||||
|
||||
await CalendarRepository.upsertReportedScores({
|
||||
eventId: parsedParams.id,
|
||||
participantCount: parsedInput.data.participantCount,
|
||||
results: parsedInput.data.team.map((t) => ({
|
||||
teamName: t.teamName,
|
||||
placement: t.placement,
|
||||
players: t.players.map((p) => ({
|
||||
userId: typeof p === "string" ? null : p.id,
|
||||
name: typeof p === "string" ? p : null,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
throw redirect(calendarEventPage(parsedParams.id));
|
||||
};
|
||||
54
app/features/calendar/actions/calendar.$id.server.ts
Normal file
54
app/features/calendar/actions/calendar.$id.server.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
tournamentManagerData,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { canDeleteCalendarEvent } from "~/permissions";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { errorToastIfFalsy, notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import { CALENDAR_PAGE } from "~/utils/urls";
|
||||
import { actualNumber, id } from "~/utils/zod";
|
||||
|
||||
export const action: ActionFunction = async ({ params, request }) => {
|
||||
const user = await requireUserId(request);
|
||||
const parsedParams = z
|
||||
.object({ id: z.preprocess(actualNumber, id) })
|
||||
.parse(params);
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({ id: parsedParams.id }),
|
||||
);
|
||||
|
||||
if (event.tournamentId) {
|
||||
errorToastIfFalsy(
|
||||
tournamentManagerData(event.tournamentId).stage.length === 0,
|
||||
"Tournament has already started",
|
||||
);
|
||||
} else {
|
||||
errorToastIfFalsy(
|
||||
canDeleteCalendarEvent({
|
||||
user,
|
||||
event,
|
||||
startTime: databaseTimestampToDate(event.startTimes[0]),
|
||||
}),
|
||||
"Cannot delete event",
|
||||
);
|
||||
}
|
||||
|
||||
await CalendarRepository.deleteById({
|
||||
eventId: event.eventId,
|
||||
tournamentId: event.tournamentId,
|
||||
});
|
||||
|
||||
if (event.tournamentId) {
|
||||
clearTournamentDataCache(event.tournamentId);
|
||||
ShowcaseTournaments.clearParticipationInfoMap();
|
||||
ShowcaseTournaments.clearCachedTournaments();
|
||||
}
|
||||
|
||||
throw redirect(CALENDAR_PAGE);
|
||||
};
|
||||
88
app/features/calendar/calendar-schemas.ts
Normal file
88
app/features/calendar/calendar-schemas.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { z } from "zod";
|
||||
import { CALENDAR_EVENT_RESULT } from "~/constants";
|
||||
import {
|
||||
actualNumber,
|
||||
id,
|
||||
safeJSONParse,
|
||||
safeSplit,
|
||||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { calendarEventTagSchema } from "./actions/calendar.new.server";
|
||||
|
||||
const playersSchema = z
|
||||
.array(
|
||||
z.union([
|
||||
z.string().min(1).max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH),
|
||||
z.object({ id }),
|
||||
]),
|
||||
)
|
||||
.nonempty({ message: "forms.errors.emptyTeam" })
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH)
|
||||
.refine(
|
||||
(val) => {
|
||||
const userIds = val.flatMap((user) =>
|
||||
typeof user === "string" ? [] : user.id,
|
||||
);
|
||||
|
||||
return userIds.length === new Set(userIds).size;
|
||||
},
|
||||
{
|
||||
message: "forms.errors.duplicatePlayer",
|
||||
},
|
||||
);
|
||||
|
||||
export const reportWinnersActionSchema = z.object({
|
||||
participantCount: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT),
|
||||
),
|
||||
team: z.preprocess(
|
||||
toArray,
|
||||
z
|
||||
.array(
|
||||
z.preprocess(
|
||||
safeJSONParse,
|
||||
z.object({
|
||||
teamName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH),
|
||||
placement: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT),
|
||||
),
|
||||
players: playersSchema,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.refine(
|
||||
(val) => val.length === new Set(val.map((team) => team.teamName)).size,
|
||||
{ message: "forms.errors.uniqueTeamName" },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
export const reportWinnersParamsSchema = z.object({
|
||||
id: z.preprocess(actualNumber, id),
|
||||
});
|
||||
|
||||
export const loaderWeekSearchParamsSchema = z.object({
|
||||
week: z.preprocess(actualNumber, z.number().int().min(1).max(53)),
|
||||
year: z.preprocess(actualNumber, z.number().int()),
|
||||
});
|
||||
|
||||
export const loaderFilterSearchParamsSchema = z.object({
|
||||
tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)),
|
||||
});
|
||||
|
||||
export const loaderTournamentsOnlySearchParamsSchema = z.object({
|
||||
tournaments: z.literal("true").nullish(),
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { canReportCalendarEventWinners } from "~/permissions";
|
||||
import { notFoundIfFalsy, unauthorizedIfFalsy } from "~/utils/remix.server";
|
||||
import { reportWinnersParamsSchema } from "../calendar-schemas";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const parsedParams = reportWinnersParamsSchema.parse(params);
|
||||
const user = await requireUserId(request);
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({ id: parsedParams.id }),
|
||||
);
|
||||
|
||||
unauthorizedIfFalsy(
|
||||
canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes: event.startTimes,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
name: event.name,
|
||||
participantCount: event.participantCount,
|
||||
winners: await CalendarRepository.findResultsByEventId(parsedParams.id),
|
||||
};
|
||||
};
|
||||
29
app/features/calendar/loaders/calendar.$id.server.ts
Normal file
29
app/features/calendar/loaders/calendar.$id.server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import { tournamentPage } from "~/utils/urls";
|
||||
import { actualNumber, id } from "~/utils/zod";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const parsedParams = z
|
||||
.object({ id: z.preprocess(actualNumber, id) })
|
||||
.parse(params);
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({
|
||||
id: parsedParams.id,
|
||||
includeBadgePrizes: true,
|
||||
includeMapPool: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (event.tournamentId) {
|
||||
throw redirect(tournamentPage(event.tournamentId));
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
results: await CalendarRepository.findResultsByEventId(parsedParams.id),
|
||||
};
|
||||
};
|
||||
105
app/features/calendar/loaders/calendar.server.ts
Normal file
105
app/features/calendar/loaders/calendar.server.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { addMonths, subMonths } from "date-fns";
|
||||
import type { PersistedCalendarEventTag } from "~/db/tables";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import {
|
||||
dateToThisWeeksMonday,
|
||||
dateToThisWeeksSunday,
|
||||
dateToWeekNumber,
|
||||
weekNumberToDate,
|
||||
} from "~/utils/dates";
|
||||
import * as CalendarRepository from "../CalendarRepository.server";
|
||||
import {
|
||||
loaderFilterSearchParamsSchema,
|
||||
loaderTournamentsOnlySearchParamsSchema,
|
||||
loaderWeekSearchParamsSchema,
|
||||
} from "../calendar-schemas";
|
||||
import { closeByWeeks } from "../calendar-utils";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const url = new URL(request.url);
|
||||
|
||||
// separate from tags parse so they can fail independently
|
||||
const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({
|
||||
year: url.searchParams.get("year"),
|
||||
week: url.searchParams.get("week"),
|
||||
});
|
||||
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
|
||||
tags: url.searchParams.get("tags"),
|
||||
});
|
||||
const parsedTournamentsOnlyParams =
|
||||
loaderTournamentsOnlySearchParamsSchema.safeParse({
|
||||
tournaments: url.searchParams.get("tournaments"),
|
||||
});
|
||||
|
||||
const mondayDate = dateToThisWeeksMonday(new Date());
|
||||
const sundayDate = dateToThisWeeksSunday(new Date());
|
||||
const currentWeek = dateToWeekNumber(mondayDate);
|
||||
|
||||
const displayedWeek = parsedWeekParams.success
|
||||
? parsedWeekParams.data.week
|
||||
: currentWeek;
|
||||
const displayedYear = parsedWeekParams.success
|
||||
? parsedWeekParams.data.year
|
||||
: currentWeek === 1 // handle first week of the year special case
|
||||
? sundayDate.getFullYear()
|
||||
: mondayDate.getFullYear();
|
||||
const tagsToFilterBy = parsedFilterParams.success
|
||||
? (parsedFilterParams.data.tags as PersistedCalendarEventTag[])
|
||||
: [];
|
||||
const onlyTournaments = parsedTournamentsOnlyParams.success
|
||||
? Boolean(parsedTournamentsOnlyParams.data.tournaments)
|
||||
: false;
|
||||
|
||||
return {
|
||||
currentWeek,
|
||||
displayedWeek,
|
||||
currentDay: new Date().getDay(),
|
||||
nearbyStartTimes: await CalendarRepository.startTimesOfRange({
|
||||
startTime: subMonths(
|
||||
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
|
||||
1,
|
||||
),
|
||||
endTime: addMonths(
|
||||
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
|
||||
1,
|
||||
),
|
||||
tagsToFilterBy,
|
||||
onlyTournaments,
|
||||
}),
|
||||
weeks: closeByWeeks({ week: displayedWeek, year: displayedYear }),
|
||||
events: await fetchEventsOfWeek({
|
||||
week: displayedWeek,
|
||||
year: displayedYear,
|
||||
tagsToFilterBy,
|
||||
onlyTournaments,
|
||||
}),
|
||||
eventsToReport: user
|
||||
? await CalendarRepository.eventsToReport(user.id)
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
function fetchEventsOfWeek(args: {
|
||||
week: number;
|
||||
year: number;
|
||||
tagsToFilterBy: PersistedCalendarEventTag[];
|
||||
onlyTournaments: boolean;
|
||||
}) {
|
||||
const startTime = weekNumberToDate(args);
|
||||
|
||||
const endTime = new Date(startTime);
|
||||
endTime.setDate(endTime.getDate() + 7);
|
||||
|
||||
// handle timezone mismatch between server and client
|
||||
startTime.setHours(startTime.getHours() - 12);
|
||||
endTime.setHours(endTime.getHours() + 12);
|
||||
|
||||
return CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy: args.tagsToFilterBy,
|
||||
onlyTournaments: args.onlyTournaments,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,14 +1,8 @@
|
|||
import { redirect } from "@remix-run/node";
|
||||
import type {
|
||||
ActionFunction,
|
||||
LoaderFunctionArgs,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { SerializeFrom } from "@remix-run/node";
|
||||
import { Form, useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/Button";
|
||||
import { FormErrors } from "~/components/FormErrors";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
|
|
@ -16,153 +10,17 @@ import { Label } from "~/components/Label";
|
|||
import { Main } from "~/components/Main";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { CALENDAR_EVENT_RESULT } from "~/constants";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { canReportCalendarEventWinners } from "~/permissions";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
safeParseRequestFormData,
|
||||
unauthorizedIfFalsy,
|
||||
} from "~/utils/remix.server";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { calendarEventPage } from "~/utils/urls";
|
||||
import { actualNumber, id, safeJSONParse, toArray } from "~/utils/zod";
|
||||
|
||||
const playersSchema = z
|
||||
.array(
|
||||
z.union([
|
||||
z.string().min(1).max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH),
|
||||
z.object({ id }),
|
||||
]),
|
||||
)
|
||||
.nonempty({ message: "forms.errors.emptyTeam" })
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH)
|
||||
.refine(
|
||||
(val) => {
|
||||
const userIds = val.flatMap((user) =>
|
||||
typeof user === "string" ? [] : user.id,
|
||||
);
|
||||
|
||||
return userIds.length === new Set(userIds).size;
|
||||
},
|
||||
{
|
||||
message: "forms.errors.duplicatePlayer",
|
||||
},
|
||||
);
|
||||
|
||||
const reportWinnersActionSchema = z.object({
|
||||
participantCount: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT),
|
||||
),
|
||||
team: z.preprocess(
|
||||
toArray,
|
||||
z
|
||||
.array(
|
||||
z.preprocess(
|
||||
safeJSONParse,
|
||||
z.object({
|
||||
teamName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH),
|
||||
placement: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT),
|
||||
),
|
||||
players: playersSchema,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.refine(
|
||||
(val) => val.length === new Set(val.map((team) => team.teamName)).size,
|
||||
{ message: "forms.errors.uniqueTeamName" },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const reportWinnersParamsSchema = z.object({
|
||||
id: z.preprocess(actualNumber, id),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUserId(request);
|
||||
const parsedParams = reportWinnersParamsSchema.parse(params);
|
||||
const parsedInput = await safeParseRequestFormData({
|
||||
request,
|
||||
schema: reportWinnersActionSchema,
|
||||
});
|
||||
|
||||
if (!parsedInput.success) {
|
||||
return {
|
||||
errors: parsedInput.errors,
|
||||
};
|
||||
}
|
||||
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({ id: parsedParams.id }),
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes: event.startTimes,
|
||||
}),
|
||||
"Unauthorized",
|
||||
);
|
||||
|
||||
await CalendarRepository.upsertReportedScores({
|
||||
eventId: parsedParams.id,
|
||||
participantCount: parsedInput.data.participantCount,
|
||||
results: parsedInput.data.team.map((t) => ({
|
||||
teamName: t.teamName,
|
||||
placement: t.placement,
|
||||
players: t.players.map((p) => ({
|
||||
userId: typeof p === "string" ? null : p.id,
|
||||
name: typeof p === "string" ? p : null,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
|
||||
throw redirect(calendarEventPage(parsedParams.id));
|
||||
};
|
||||
import { action } from "../actions/calendar.$id.report-winners.server";
|
||||
import { loader } from "../loaders/calendar.$id.report-winners.server";
|
||||
export { loader, action };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "calendar",
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const parsedParams = reportWinnersParamsSchema.parse(params);
|
||||
const user = await requireUserId(request);
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({ id: parsedParams.id }),
|
||||
);
|
||||
|
||||
unauthorizedIfFalsy(
|
||||
canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes: event.startTimes,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
name: event.name,
|
||||
participantCount: event.participantCount,
|
||||
winners: await CalendarRepository.findResultsByEventId(parsedParams.id),
|
||||
};
|
||||
};
|
||||
|
||||
export default function ReportWinnersPage() {
|
||||
const { t } = useTranslation(["common", "calendar"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import type {
|
||||
ActionFunction,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { Link } from "@remix-run/react/dist/components";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
|
|
@ -21,14 +14,7 @@ import { Placement } from "~/components/Placement";
|
|||
import { Section } from "~/components/Section";
|
||||
import { Table } from "~/components/Table";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
tournamentManagerData,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import {
|
||||
canDeleteCalendarEvent,
|
||||
|
|
@ -36,11 +22,7 @@ import {
|
|||
canReportCalendarEventWinners,
|
||||
} from "~/permissions";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
} from "~/utils/remix.server";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
CALENDAR_PAGE,
|
||||
calendarEditPage,
|
||||
|
|
@ -49,55 +31,18 @@ import {
|
|||
navIconUrl,
|
||||
readonlyMapsPage,
|
||||
resolveBaseUrl,
|
||||
tournamentPage,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { actualNumber, id } from "~/utils/zod";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import { Tags } from "../components/Tags";
|
||||
|
||||
import { action } from "../actions/calendar.$id.server";
|
||||
import { loader } from "../loaders/calendar.$id.server";
|
||||
export { loader, action };
|
||||
|
||||
import "~/styles/calendar-event.css";
|
||||
import "~/styles/maps.css";
|
||||
|
||||
export const action: ActionFunction = async ({ params, request }) => {
|
||||
const user = await requireUserId(request);
|
||||
const parsedParams = z
|
||||
.object({ id: z.preprocess(actualNumber, id) })
|
||||
.parse(params);
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({ id: parsedParams.id }),
|
||||
);
|
||||
|
||||
if (event.tournamentId) {
|
||||
errorToastIfFalsy(
|
||||
tournamentManagerData(event.tournamentId).stage.length === 0,
|
||||
"Tournament has already started",
|
||||
);
|
||||
} else {
|
||||
errorToastIfFalsy(
|
||||
canDeleteCalendarEvent({
|
||||
user,
|
||||
event,
|
||||
startTime: databaseTimestampToDate(event.startTimes[0]),
|
||||
}),
|
||||
"Cannot delete event",
|
||||
);
|
||||
}
|
||||
|
||||
await CalendarRepository.deleteById({
|
||||
eventId: event.eventId,
|
||||
tournamentId: event.tournamentId,
|
||||
});
|
||||
|
||||
if (event.tournamentId) {
|
||||
clearTournamentDataCache(event.tournamentId);
|
||||
ShowcaseTournaments.clearParticipationInfoMap();
|
||||
ShowcaseTournaments.clearCachedTournaments();
|
||||
}
|
||||
|
||||
throw redirect(CALENDAR_PAGE);
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader>;
|
||||
|
||||
|
|
@ -134,28 +79,6 @@ export const handle: SendouRouteHandle = {
|
|||
},
|
||||
};
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const parsedParams = z
|
||||
.object({ id: z.preprocess(actualNumber, id) })
|
||||
.parse(params);
|
||||
const event = notFoundIfFalsy(
|
||||
await CalendarRepository.findById({
|
||||
id: parsedParams.id,
|
||||
includeBadgePrizes: true,
|
||||
includeMapPool: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (event.tournamentId) {
|
||||
throw redirect(tournamentPage(event.tournamentId));
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
results: await CalendarRepository.findResultsByEventId(parsedParams.id),
|
||||
};
|
||||
};
|
||||
|
||||
export default function CalendarEventPage() {
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import "~/styles/calendar-new.css";
|
|||
import "~/styles/maps.css";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
import { action } from "../actions/calendar.new.server";
|
||||
import { loader } from "../loaders/calendar.new.server";
|
||||
export { loader, action };
|
||||
|
|
|
|||
|
|
@ -1,30 +1,23 @@
|
|||
import type {
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { addMonths, subMonths } from "date-fns";
|
||||
import React from "react";
|
||||
import { Flipped, Flipper } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import type { CalendarEventTag } from "~/db/tables";
|
||||
import { currentSeason } from "~/features/mmr/season";
|
||||
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToThisWeeksMonday,
|
||||
dateToThisWeeksSunday,
|
||||
dateToWeekNumber,
|
||||
dayToWeekStartsAtMondayDay,
|
||||
getWeekStartsAtMondayDay,
|
||||
|
|
@ -41,17 +34,15 @@ import {
|
|||
tournamentPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import { actualNumber, safeSplit } from "~/utils/zod";
|
||||
import { Label } from "../../../components/Label";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import * as CalendarRepository from "../CalendarRepository.server";
|
||||
import { calendarEventTagSchema } from "../actions/calendar.new.server";
|
||||
import { CALENDAR_EVENT } from "../calendar-constants";
|
||||
import { closeByWeeks } from "../calendar-utils";
|
||||
import { Tags } from "../components/Tags";
|
||||
|
||||
import { loader } from "../loaders/calendar.server";
|
||||
export { loader };
|
||||
|
||||
import "~/styles/calendar.css";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import type { CalendarEventTag, PersistedCalendarEventTag } from "~/db/tables";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader> | null;
|
||||
|
|
@ -89,107 +80,6 @@ export const handle: SendouRouteHandle = {
|
|||
}),
|
||||
};
|
||||
|
||||
const loaderWeekSearchParamsSchema = z.object({
|
||||
week: z.preprocess(actualNumber, z.number().int().min(1).max(53)),
|
||||
year: z.preprocess(actualNumber, z.number().int()),
|
||||
});
|
||||
|
||||
const loaderFilterSearchParamsSchema = z.object({
|
||||
tags: z.preprocess(safeSplit(), z.array(calendarEventTagSchema)),
|
||||
});
|
||||
|
||||
const loaderTournamentsOnlySearchParamsSchema = z.object({
|
||||
tournaments: z.literal("true").nullish(),
|
||||
});
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const url = new URL(request.url);
|
||||
|
||||
// separate from tags parse so they can fail independently
|
||||
const parsedWeekParams = loaderWeekSearchParamsSchema.safeParse({
|
||||
year: url.searchParams.get("year"),
|
||||
week: url.searchParams.get("week"),
|
||||
});
|
||||
const parsedFilterParams = loaderFilterSearchParamsSchema.safeParse({
|
||||
tags: url.searchParams.get("tags"),
|
||||
});
|
||||
const parsedTournamentsOnlyParams =
|
||||
loaderTournamentsOnlySearchParamsSchema.safeParse({
|
||||
tournaments: url.searchParams.get("tournaments"),
|
||||
});
|
||||
|
||||
const mondayDate = dateToThisWeeksMonday(new Date());
|
||||
const sundayDate = dateToThisWeeksSunday(new Date());
|
||||
const currentWeek = dateToWeekNumber(mondayDate);
|
||||
|
||||
const displayedWeek = parsedWeekParams.success
|
||||
? parsedWeekParams.data.week
|
||||
: currentWeek;
|
||||
const displayedYear = parsedWeekParams.success
|
||||
? parsedWeekParams.data.year
|
||||
: currentWeek === 1 // handle first week of the year special case
|
||||
? sundayDate.getFullYear()
|
||||
: mondayDate.getFullYear();
|
||||
const tagsToFilterBy = parsedFilterParams.success
|
||||
? (parsedFilterParams.data.tags as PersistedCalendarEventTag[])
|
||||
: [];
|
||||
const onlyTournaments = parsedTournamentsOnlyParams.success
|
||||
? Boolean(parsedTournamentsOnlyParams.data.tournaments)
|
||||
: false;
|
||||
|
||||
return {
|
||||
currentWeek,
|
||||
displayedWeek,
|
||||
currentDay: new Date().getDay(),
|
||||
nearbyStartTimes: await CalendarRepository.startTimesOfRange({
|
||||
startTime: subMonths(
|
||||
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
|
||||
1,
|
||||
),
|
||||
endTime: addMonths(
|
||||
weekNumberToDate({ week: displayedWeek, year: displayedYear }),
|
||||
1,
|
||||
),
|
||||
tagsToFilterBy,
|
||||
onlyTournaments,
|
||||
}),
|
||||
weeks: closeByWeeks({ week: displayedWeek, year: displayedYear }),
|
||||
events: await fetchEventsOfWeek({
|
||||
week: displayedWeek,
|
||||
year: displayedYear,
|
||||
tagsToFilterBy,
|
||||
onlyTournaments,
|
||||
}),
|
||||
eventsToReport: user
|
||||
? await CalendarRepository.eventsToReport(user.id)
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
function fetchEventsOfWeek(args: {
|
||||
week: number;
|
||||
year: number;
|
||||
tagsToFilterBy: PersistedCalendarEventTag[];
|
||||
onlyTournaments: boolean;
|
||||
}) {
|
||||
const startTime = weekNumberToDate(args);
|
||||
|
||||
const endTime = new Date(startTime);
|
||||
endTime.setDate(endTime.getDate() + 7);
|
||||
|
||||
// handle timezone mismatch between server and client
|
||||
startTime.setHours(startTime.getHours() - 12);
|
||||
endTime.setHours(endTime.getHours() + 12);
|
||||
|
||||
return CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
startTime,
|
||||
endTime,
|
||||
tagsToFilterBy: args.tagsToFilterBy,
|
||||
onlyTournaments: args.onlyTournaments,
|
||||
});
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const { t } = useTranslation("calendar");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ import {
|
|||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import type * as ShowcaseTournaments from "../core/ShowcaseTournaments.server";
|
||||
import { type LeaderboardEntry, loader } from "../loaders/index.server";
|
||||
|
||||
import { type LeaderboardEntry, loader } from "../loaders/index.server";
|
||||
export { loader };
|
||||
|
||||
import "~/styles/front.css";
|
||||
|
|
|
|||
32
app/features/img-upload/actions/upload.admin.server.ts
Normal file
32
app/features/img-upload/actions/upload.admin.server.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import * as ImageRepository from "../ImageRepository.server";
|
||||
import { validateImage } from "../queries/validateImage";
|
||||
import { validateImageSchema } from "../upload-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestPayload({
|
||||
schema: validateImageSchema,
|
||||
request,
|
||||
});
|
||||
|
||||
errorToastIfFalsy(isMod(user), "Only admins can validate images");
|
||||
|
||||
const image = badRequestIfFalsy(await ImageRepository.findById(data.imageId));
|
||||
|
||||
validateImage(data.imageId);
|
||||
|
||||
if (image.tournamentId) {
|
||||
clearTournamentDataCache(image.tournamentId);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
17
app/features/img-upload/loaders/upload.admin.server.ts
Normal file
17
app/features/img-upload/loaders/upload.admin.server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import { countAllUnvalidatedImg } from "../queries/countAllUnvalidatedImg.server";
|
||||
import { oneUnvalidatedImage } from "../queries/oneUnvalidatedImage";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
|
||||
notFoundIfFalsy(isMod(user));
|
||||
|
||||
return {
|
||||
image: oneUnvalidatedImage(),
|
||||
unvalidatedImgCount: countAllUnvalidatedImg(),
|
||||
};
|
||||
};
|
||||
30
app/features/img-upload/loaders/upload.server.ts
Normal file
30
app/features/img-upload/loaders/upload.server.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import { isTeamManager } from "~/features/team/team-utils";
|
||||
import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server";
|
||||
import { requestToImgType } from "../upload-utils";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const validatedType = requestToImgType(request);
|
||||
|
||||
if (!validatedType) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
if (validatedType === "team-pfp" || validatedType === "team-banner") {
|
||||
const teamCustomUrl = new URL(request.url).searchParams.get("team") ?? "";
|
||||
const team = await TeamRepository.findByCustomUrl(teamCustomUrl);
|
||||
|
||||
if (!team || !isTeamManager({ team, user })) {
|
||||
throw redirect("/");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: validatedType,
|
||||
unvalidatedImages: countUnvalidatedImg(user.id),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,53 +1,11 @@
|
|||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { Form, useLoaderData } from "@remix-run/react";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { userSubmittedImage } from "~/utils/urls";
|
||||
import * as ImageRepository from "../ImageRepository.server";
|
||||
import { countAllUnvalidatedImg } from "../queries/countAllUnvalidatedImg.server";
|
||||
import { oneUnvalidatedImage } from "../queries/oneUnvalidatedImage";
|
||||
import { validateImage } from "../queries/validateImage";
|
||||
import { validateImageSchema } from "../upload-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestPayload({
|
||||
schema: validateImageSchema,
|
||||
request,
|
||||
});
|
||||
|
||||
errorToastIfFalsy(isMod(user), "Only admins can validate images");
|
||||
|
||||
const image = badRequestIfFalsy(await ImageRepository.findById(data.imageId));
|
||||
|
||||
validateImage(data.imageId);
|
||||
|
||||
if (image.tournamentId) {
|
||||
clearTournamentDataCache(image.tournamentId);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
|
||||
notFoundIfFalsy(isMod(user));
|
||||
|
||||
return {
|
||||
image: oneUnvalidatedImage(),
|
||||
unvalidatedImgCount: countAllUnvalidatedImg(),
|
||||
};
|
||||
};
|
||||
import { action } from "../actions/upload.admin.server";
|
||||
import { loader } from "../loaders/upload.admin.server";
|
||||
export { action, loader };
|
||||
|
||||
export default function ImageUploadAdminPage() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,44 +1,16 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import Compressor from "compressorjs";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Main } from "~/components/Main";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import { isTeamManager } from "~/features/team/team-utils";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { action } from "../actions/upload.server";
|
||||
import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server";
|
||||
import { imgTypeToDimensions, imgTypeToStyle } from "../upload-constants";
|
||||
import type { ImageUploadType } from "../upload-types";
|
||||
import { requestToImgType } from "../upload-utils";
|
||||
export { action };
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const validatedType = requestToImgType(request);
|
||||
|
||||
if (!validatedType) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
if (validatedType === "team-pfp" || validatedType === "team-banner") {
|
||||
const teamCustomUrl = new URL(request.url).searchParams.get("team") ?? "";
|
||||
const team = await TeamRepository.findByCustomUrl(teamCustomUrl);
|
||||
|
||||
if (!team || !isTeamManager({ team, user })) {
|
||||
throw redirect("/");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: validatedType,
|
||||
unvalidatedImages: countUnvalidatedImg(user.id),
|
||||
};
|
||||
};
|
||||
import { action } from "../actions/upload.server";
|
||||
import { loader } from "../loaders/upload.server";
|
||||
export { action, loader };
|
||||
|
||||
export default function FileUploadPage() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
|
|
|||
|
|
@ -27,3 +27,6 @@ export const LEADERBOARD_TYPES = [
|
|||
export const IGNORED_TEAMS: Map<number, number[][]> = new Map().set(5, [
|
||||
[9403, 13562, 15916, 38062], // Snooze
|
||||
]);
|
||||
|
||||
export const TYPE_SEARCH_PARAM_KEY = "type";
|
||||
export const SEASON_SEARCH_PARAM_KEY = "season";
|
||||
|
|
|
|||
103
app/features/leaderboards/loaders/leaderboards.server.ts
Normal file
103
app/features/leaderboards/loaders/leaderboards.server.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { cachified } from "@epic-web/cachified";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { HALF_HOUR_IN_MS } from "~/constants";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import { allSeasons, currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
RankedModeShort,
|
||||
weaponCategories,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { cache, ttl } from "~/utils/cache.server";
|
||||
import {
|
||||
cachedFullUserLeaderboard,
|
||||
filterByWeaponCategory,
|
||||
ownEntryPeek,
|
||||
} from "../core/leaderboards.server";
|
||||
import {
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
LEADERBOARD_TYPES,
|
||||
SEASON_SEARCH_PARAM_KEY,
|
||||
TYPE_SEARCH_PARAM_KEY,
|
||||
WEAPON_LEADERBOARD_MAX_SIZE,
|
||||
} from "../leaderboards-constants";
|
||||
import {
|
||||
allXPLeaderboard,
|
||||
modeXPLeaderboard,
|
||||
weaponXPLeaderboard,
|
||||
} from "../queries/XPLeaderboard.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
const unvalidatedType = new URL(request.url).searchParams.get(
|
||||
TYPE_SEARCH_PARAM_KEY,
|
||||
);
|
||||
const unvalidatedSeason = new URL(request.url).searchParams.get(
|
||||
SEASON_SEARCH_PARAM_KEY,
|
||||
);
|
||||
|
||||
const type =
|
||||
LEADERBOARD_TYPES.find((type) => type === unvalidatedType) ??
|
||||
LEADERBOARD_TYPES[0];
|
||||
const season =
|
||||
allSeasons(new Date()).find(
|
||||
(s) => unvalidatedSeason && s === Number(unvalidatedSeason),
|
||||
) ?? currentOrPreviousSeason(new Date())!.nth;
|
||||
|
||||
const fullUserLeaderboard = type.includes("USER")
|
||||
? await cachedFullUserLeaderboard(season)
|
||||
: null;
|
||||
|
||||
const userLeaderboard = fullUserLeaderboard?.slice(
|
||||
0,
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
);
|
||||
|
||||
const teamLeaderboard =
|
||||
type === "TEAM" || type === "TEAM-ALL"
|
||||
? await cachified({
|
||||
key: `team-leaderboard-season-${season}-${type}`,
|
||||
cache,
|
||||
ttl: ttl(HALF_HOUR_IN_MS),
|
||||
async getFreshValue() {
|
||||
return LeaderboardRepository.teamLeaderboardBySeason({
|
||||
season,
|
||||
onlyOneEntryPerUser: type !== "TEAM-ALL",
|
||||
});
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const isWeaponLeaderboard = userLeaderboard && type !== "USER";
|
||||
|
||||
const filteredLeaderboard = isWeaponLeaderboard
|
||||
? filterByWeaponCategory(
|
||||
fullUserLeaderboard!,
|
||||
type.split("-")[1] as (typeof weaponCategories)[number]["name"],
|
||||
).slice(0, WEAPON_LEADERBOARD_MAX_SIZE)
|
||||
: userLeaderboard;
|
||||
|
||||
const showOwnEntryPeek = fullUserLeaderboard && !isWeaponLeaderboard && user;
|
||||
|
||||
return {
|
||||
userLeaderboard: filteredLeaderboard ?? userLeaderboard,
|
||||
ownEntryPeek: showOwnEntryPeek
|
||||
? ownEntryPeek({
|
||||
leaderboard: fullUserLeaderboard,
|
||||
season,
|
||||
userId: user.id,
|
||||
})
|
||||
: null,
|
||||
teamLeaderboard,
|
||||
xpLeaderboard:
|
||||
type === "XP-ALL"
|
||||
? allXPLeaderboard()
|
||||
: type.startsWith("XP-MODE")
|
||||
? modeXPLeaderboard(type.split("-")[2] as RankedModeShort)
|
||||
: type.startsWith("XP-WEAPON")
|
||||
? weaponXPLeaderboard(Number(type.split("-")[2]) as MainWeaponId)
|
||||
: null,
|
||||
season,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,32 +1,15 @@
|
|||
import { cachified } from "@epic-web/cachified";
|
||||
import type {
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { TierImage, WeaponImage } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { HALF_HOUR_IN_MS } from "~/constants";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import { ordinalToSp } from "~/features/mmr/mmr-utils";
|
||||
import {
|
||||
allSeasons,
|
||||
currentOrPreviousSeason,
|
||||
currentSeason,
|
||||
} from "~/features/mmr/season";
|
||||
import { allSeasons, currentSeason } from "~/features/mmr/season";
|
||||
import type { SkillTierInterval } from "~/features/mmr/tiered.server";
|
||||
import {
|
||||
type MainWeaponId,
|
||||
type RankedModeShort,
|
||||
weaponCategories,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { weaponCategories } from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { cache, ttl } from "~/utils/cache.server";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
|
|
@ -41,22 +24,15 @@ import {
|
|||
import { InfoPopover } from "../../../components/InfoPopover";
|
||||
import { TopTenPlayer } from "../components/TopTenPlayer";
|
||||
import {
|
||||
cachedFullUserLeaderboard,
|
||||
filterByWeaponCategory,
|
||||
ownEntryPeek,
|
||||
} from "../core/leaderboards.server";
|
||||
import {
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
LEADERBOARD_TYPES,
|
||||
WEAPON_LEADERBOARD_MAX_SIZE,
|
||||
SEASON_SEARCH_PARAM_KEY,
|
||||
TYPE_SEARCH_PARAM_KEY,
|
||||
} from "../leaderboards-constants";
|
||||
import { seasonHasTopTen } from "../leaderboards-utils";
|
||||
import {
|
||||
type XPLeaderboardItem,
|
||||
allXPLeaderboard,
|
||||
modeXPLeaderboard,
|
||||
weaponXPLeaderboard,
|
||||
} from "../queries/XPLeaderboard.server";
|
||||
import type { XPLeaderboardItem } from "../queries/XPLeaderboard.server";
|
||||
|
||||
import { loader } from "../loaders/leaderboards.server";
|
||||
export { loader };
|
||||
|
||||
import "../../top-search/top-search.css";
|
||||
|
||||
|
|
@ -83,83 +59,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
const TYPE_SEARCH_PARAM_KEY = "type";
|
||||
const SEASON_SEARCH_PARAM_KEY = "season";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
const unvalidatedType = new URL(request.url).searchParams.get(
|
||||
TYPE_SEARCH_PARAM_KEY,
|
||||
);
|
||||
const unvalidatedSeason = new URL(request.url).searchParams.get(
|
||||
SEASON_SEARCH_PARAM_KEY,
|
||||
);
|
||||
|
||||
const type =
|
||||
LEADERBOARD_TYPES.find((type) => type === unvalidatedType) ??
|
||||
LEADERBOARD_TYPES[0];
|
||||
const season =
|
||||
allSeasons(new Date()).find(
|
||||
(s) => unvalidatedSeason && s === Number(unvalidatedSeason),
|
||||
) ?? currentOrPreviousSeason(new Date())!.nth;
|
||||
|
||||
const fullUserLeaderboard = type.includes("USER")
|
||||
? await cachedFullUserLeaderboard(season)
|
||||
: null;
|
||||
|
||||
const userLeaderboard = fullUserLeaderboard?.slice(
|
||||
0,
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
);
|
||||
|
||||
const teamLeaderboard =
|
||||
type === "TEAM" || type === "TEAM-ALL"
|
||||
? await cachified({
|
||||
key: `team-leaderboard-season-${season}-${type}`,
|
||||
cache,
|
||||
ttl: ttl(HALF_HOUR_IN_MS),
|
||||
async getFreshValue() {
|
||||
return LeaderboardRepository.teamLeaderboardBySeason({
|
||||
season,
|
||||
onlyOneEntryPerUser: type !== "TEAM-ALL",
|
||||
});
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const isWeaponLeaderboard = userLeaderboard && type !== "USER";
|
||||
|
||||
const filteredLeaderboard = isWeaponLeaderboard
|
||||
? filterByWeaponCategory(
|
||||
fullUserLeaderboard!,
|
||||
type.split("-")[1] as (typeof weaponCategories)[number]["name"],
|
||||
).slice(0, WEAPON_LEADERBOARD_MAX_SIZE)
|
||||
: userLeaderboard;
|
||||
|
||||
const showOwnEntryPeek = fullUserLeaderboard && !isWeaponLeaderboard && user;
|
||||
|
||||
return {
|
||||
userLeaderboard: filteredLeaderboard ?? userLeaderboard,
|
||||
ownEntryPeek: showOwnEntryPeek
|
||||
? ownEntryPeek({
|
||||
leaderboard: fullUserLeaderboard,
|
||||
season,
|
||||
userId: user.id,
|
||||
})
|
||||
: null,
|
||||
teamLeaderboard,
|
||||
xpLeaderboard:
|
||||
type === "XP-ALL"
|
||||
? allXPLeaderboard()
|
||||
: type.startsWith("XP-MODE")
|
||||
? modeXPLeaderboard(type.split("-")[2] as RankedModeShort)
|
||||
: type.startsWith("XP-WEAPON")
|
||||
? weaponXPLeaderboard(Number(type.split("-")[2]) as MainWeaponId)
|
||||
: null,
|
||||
season,
|
||||
};
|
||||
};
|
||||
|
||||
export default function LeaderboardsPage() {
|
||||
const { t } = useTranslation(["common", "game-misc", "weapons"]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
|
|||
29
app/features/map-list-generator/loaders/maps.server.ts
Normal file
29
app/features/map-list-generator/loaders/maps.server.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const url = new URL(request.url);
|
||||
const calendarEventId = url.searchParams.get("eventId");
|
||||
|
||||
const event = calendarEventId
|
||||
? await CalendarRepository.findById({
|
||||
id: Number(calendarEventId),
|
||||
includeMapPool: true,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
calendarEvent: event
|
||||
? {
|
||||
id: event.eventId,
|
||||
name: event.name,
|
||||
mapPool: event.mapPool,
|
||||
}
|
||||
: undefined,
|
||||
recentEventsWithMapPools: user
|
||||
? await CalendarRepository.findRecentMapPoolsByAuthorId(user.id)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import type { ShouldRevalidateFunction } from "@remix-run/react";
|
||||
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
|
|
@ -8,11 +8,13 @@ import { Button } from "~/components/Button";
|
|||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { EditIcon } from "~/components/icons/Edit";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { type ModeWithStage, stageIds } from "~/modules/in-game-lists";
|
||||
import "~/styles/maps.css";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
MAPS_URL,
|
||||
|
|
@ -24,10 +26,9 @@ import { generateMapList } from "../core/map-list-generator/map-list";
|
|||
import { modesOrder } from "../core/map-list-generator/modes";
|
||||
import { mapPoolToNonEmptyModes } from "../core/map-list-generator/utils";
|
||||
import { MapPool } from "../core/map-pool";
|
||||
import "~/styles/maps.css";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
import { loader } from "../loaders/maps.server";
|
||||
export { loader };
|
||||
|
||||
const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2;
|
||||
|
||||
|
|
@ -57,32 +58,6 @@ export const handle: SendouRouteHandle = {
|
|||
}),
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const url = new URL(request.url);
|
||||
const calendarEventId = url.searchParams.get("eventId");
|
||||
|
||||
const event = calendarEventId
|
||||
? await CalendarRepository.findById({
|
||||
id: Number(calendarEventId),
|
||||
includeMapPool: true,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
calendarEvent: event
|
||||
? {
|
||||
id: event.eventId,
|
||||
name: event.name,
|
||||
mapPool: event.mapPool,
|
||||
}
|
||||
: undefined,
|
||||
recentEventsWithMapPools: user
|
||||
? await CalendarRepository.findRecentMapPoolsByAuthorId(user.id)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export default function MapListPage() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import { Link, type MetaFunction, useLoaderData } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Main } from "~/components/Main";
|
||||
import { BellIcon } from "~/components/icons/Bell";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import { SETTINGS_PAGE } from "../../../utils/urls";
|
||||
import {
|
||||
NotificationItem,
|
||||
NotificationItemDivider,
|
||||
NotificationsList,
|
||||
} from "../components/NotificationList";
|
||||
import { useMarkNotificationsAsSeen } from "../notifications-hooks";
|
||||
|
||||
import { loader } from "../loaders/notifications.server";
|
||||
export { loader };
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BellIcon } from "~/components/icons/Bell";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
import { SETTINGS_PAGE } from "../../../utils/urls";
|
||||
import { useMarkNotificationsAsSeen } from "../notifications-hooks";
|
||||
|
||||
import styles from "./notifications.module.css";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { canAddCommentToSuggestionBE } from "~/permissions";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { followUpCommentActionSchema } from "../plus-suggestions-schemas";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: followUpCommentActionSchema,
|
||||
});
|
||||
const user = await requireUser(request);
|
||||
|
||||
const votingMonthYear = rangeToMonthYear(
|
||||
badRequestIfFalsy(nextNonCompletedVoting(new Date())),
|
||||
);
|
||||
|
||||
const suggestions =
|
||||
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canAddCommentToSuggestionBE({
|
||||
suggestions,
|
||||
user,
|
||||
suggested: { id: data.suggestedId },
|
||||
targetPlusTier: data.tier,
|
||||
}),
|
||||
"No permissions to add this comment",
|
||||
);
|
||||
|
||||
await PlusSuggestionRepository.create({
|
||||
authorId: user.id,
|
||||
suggestedId: data.suggestedId,
|
||||
text: data.comment,
|
||||
tier: data.tier,
|
||||
...votingMonthYear,
|
||||
});
|
||||
|
||||
throw redirect(plusSuggestionPage({ tier: data.tier }));
|
||||
};
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { canSuggestNewUserBE } from "~/permissions";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { firstCommentActionSchema } from "../plus-suggestions-schemas";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: firstCommentActionSchema,
|
||||
});
|
||||
|
||||
const suggested = badRequestIfFalsy(
|
||||
await UserRepository.findLeanById(data.userId),
|
||||
);
|
||||
|
||||
const user = await requireUser(request);
|
||||
|
||||
const votingMonthYear = rangeToMonthYear(
|
||||
badRequestIfFalsy(nextNonCompletedVoting(new Date())),
|
||||
);
|
||||
const suggestions =
|
||||
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canSuggestNewUserBE({
|
||||
user,
|
||||
suggested,
|
||||
targetPlusTier: data.tier,
|
||||
suggestions,
|
||||
}),
|
||||
"No permissions to make this suggestion",
|
||||
);
|
||||
|
||||
await PlusSuggestionRepository.create({
|
||||
authorId: user.id,
|
||||
suggestedId: suggested.id,
|
||||
tier: data.tier,
|
||||
text: data.comment,
|
||||
...votingMonthYear,
|
||||
});
|
||||
|
||||
notify({
|
||||
userIds: [suggested.id],
|
||||
notification: {
|
||||
type: "PLUS_SUGGESTION_ADDED",
|
||||
meta: {
|
||||
tier: data.tier,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw redirect(plusSuggestionPage({ tier: data.tier }));
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
isVotingActive,
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { canDeleteComment, isFirstSuggestion } from "~/permissions";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { suggestionActionSchema } from "../plus-suggestions-schemas";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: suggestionActionSchema,
|
||||
});
|
||||
const user = await requireUser(request);
|
||||
|
||||
const votingMonthYear = rangeToMonthYear(
|
||||
badRequestIfFalsy(nextNonCompletedVoting(new Date())),
|
||||
);
|
||||
|
||||
switch (data._action) {
|
||||
case "DELETE_COMMENT": {
|
||||
const suggestions =
|
||||
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
|
||||
|
||||
const suggestionToDelete = suggestions.find((suggestion) =>
|
||||
suggestion.suggestions.some(
|
||||
(suggestion) => suggestion.id === data.suggestionId,
|
||||
),
|
||||
);
|
||||
invariant(suggestionToDelete);
|
||||
const subSuggestion = suggestionToDelete.suggestions.find(
|
||||
(suggestion) => suggestion.id === data.suggestionId,
|
||||
);
|
||||
invariant(subSuggestion);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canDeleteComment({
|
||||
user,
|
||||
author: subSuggestion.author,
|
||||
suggestionId: data.suggestionId,
|
||||
suggestions,
|
||||
}),
|
||||
"No permissions to delete this comment",
|
||||
);
|
||||
|
||||
const suggestionHasComments = suggestionToDelete.suggestions.length > 1;
|
||||
|
||||
if (
|
||||
suggestionHasComments &&
|
||||
isFirstSuggestion({ suggestionId: data.suggestionId, suggestions })
|
||||
) {
|
||||
// admin only action
|
||||
await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({
|
||||
tier: suggestionToDelete.tier,
|
||||
userId: suggestionToDelete.suggested.id,
|
||||
...votingMonthYear,
|
||||
});
|
||||
} else {
|
||||
await PlusSuggestionRepository.deleteById(data.suggestionId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DELETE_SUGGESTION_OF_THEMSELVES": {
|
||||
invariant(!isVotingActive(), "Voting is active");
|
||||
|
||||
await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({
|
||||
tier: data.tier,
|
||||
userId: user.id,
|
||||
...votingMonthYear,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
|
||||
export const loader = async () => {
|
||||
const nextVotingRange = nextNonCompletedVoting(new Date());
|
||||
|
||||
if (!nextVotingRange) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: await PlusSuggestionRepository.findAllByMonth(
|
||||
rangeToMonthYear(nextVotingRange),
|
||||
),
|
||||
};
|
||||
};
|
||||
54
app/features/plus-suggestions/plus-suggestions-schemas.ts
Normal file
54
app/features/plus-suggestions/plus-suggestions-schemas.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
PLUS_TIERS,
|
||||
PlUS_SUGGESTION_COMMENT_MAX_LENGTH,
|
||||
PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH,
|
||||
} from "~/constants";
|
||||
import { _action, actualNumber, trimmedString } from "~/utils/zod";
|
||||
|
||||
export const followUpCommentActionSchema = z.object({
|
||||
comment: z.preprocess(
|
||||
trimmedString,
|
||||
z.string().min(1).max(PlUS_SUGGESTION_COMMENT_MAX_LENGTH),
|
||||
),
|
||||
tier: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.min(Math.min(...PLUS_TIERS))
|
||||
.max(Math.max(...PLUS_TIERS)),
|
||||
),
|
||||
suggestedId: z.preprocess(actualNumber, z.number()),
|
||||
});
|
||||
|
||||
export const firstCommentActionSchema = z.object({
|
||||
tier: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.min(Math.min(...PLUS_TIERS))
|
||||
.max(Math.max(...PLUS_TIERS)),
|
||||
),
|
||||
comment: z.preprocess(
|
||||
trimmedString,
|
||||
z.string().min(1).max(PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH),
|
||||
),
|
||||
userId: z.preprocess(actualNumber, z.number().positive()),
|
||||
});
|
||||
|
||||
export const suggestionActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("DELETE_COMMENT"),
|
||||
suggestionId: z.preprocess(actualNumber, z.number()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("DELETE_SUGGESTION_OF_THEMSELVES"),
|
||||
tier: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.min(Math.min(...PLUS_TIERS))
|
||||
.max(Math.max(...PLUS_TIERS)),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
|
@ -1,82 +1,17 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { Form, useMatches, useParams } from "@remix-run/react";
|
||||
import { z } from "zod";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { PLUS_TIERS, PlUS_SUGGESTION_COMMENT_MAX_LENGTH } from "~/constants";
|
||||
import { PlUS_SUGGESTION_COMMENT_MAX_LENGTH } from "~/constants";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import {
|
||||
canAddCommentToSuggestionBE,
|
||||
canAddCommentToSuggestionFE,
|
||||
} from "~/permissions";
|
||||
import { canAddCommentToSuggestionFE } from "~/permissions";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { actualNumber, trimmedString } from "~/utils/zod";
|
||||
import type { PlusSuggestionsLoaderData } from "./plus.suggestions";
|
||||
import { CommentTextarea } from "./plus.suggestions.new";
|
||||
|
||||
const commentActionSchema = z.object({
|
||||
comment: z.preprocess(
|
||||
trimmedString,
|
||||
z.string().min(1).max(PlUS_SUGGESTION_COMMENT_MAX_LENGTH),
|
||||
),
|
||||
tier: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.min(Math.min(...PLUS_TIERS))
|
||||
.max(Math.max(...PLUS_TIERS)),
|
||||
),
|
||||
suggestedId: z.preprocess(actualNumber, z.number()),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: commentActionSchema,
|
||||
});
|
||||
const user = await requireUser(request);
|
||||
|
||||
const votingMonthYear = rangeToMonthYear(
|
||||
badRequestIfFalsy(nextNonCompletedVoting(new Date())),
|
||||
);
|
||||
|
||||
const suggestions =
|
||||
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canAddCommentToSuggestionBE({
|
||||
suggestions,
|
||||
user,
|
||||
suggested: { id: data.suggestedId },
|
||||
targetPlusTier: data.tier,
|
||||
}),
|
||||
"No permissions to add this comment",
|
||||
);
|
||||
|
||||
await PlusSuggestionRepository.create({
|
||||
authorId: user.id,
|
||||
suggestedId: data.suggestedId,
|
||||
text: data.comment,
|
||||
tier: data.tier,
|
||||
...votingMonthYear,
|
||||
});
|
||||
|
||||
throw redirect(plusSuggestionPage({ tier: data.tier }));
|
||||
};
|
||||
import { action } from "../actions/plus.suggestions.comment.$tier.$userId.server";
|
||||
export { action };
|
||||
|
||||
export default function PlusCommentModalPage() {
|
||||
const user = useUser();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { Form, useMatches } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
|
|
@ -16,93 +13,17 @@ import {
|
|||
} from "~/constants";
|
||||
import type { UserWithPlusTier } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import {
|
||||
canSuggestNewUserBE,
|
||||
canSuggestNewUserFE,
|
||||
playerAlreadyMember,
|
||||
playerAlreadySuggested,
|
||||
} from "~/permissions";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { actualNumber, trimmedString } from "~/utils/zod";
|
||||
import type { PlusSuggestionsLoaderData } from "./plus.suggestions";
|
||||
|
||||
const commentActionSchema = z.object({
|
||||
tier: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.min(Math.min(...PLUS_TIERS))
|
||||
.max(Math.max(...PLUS_TIERS)),
|
||||
),
|
||||
comment: z.preprocess(
|
||||
trimmedString,
|
||||
z.string().min(1).max(PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH),
|
||||
),
|
||||
userId: z.preprocess(actualNumber, z.number().positive()),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: commentActionSchema,
|
||||
});
|
||||
|
||||
const suggested = badRequestIfFalsy(
|
||||
await UserRepository.findLeanById(data.userId),
|
||||
);
|
||||
|
||||
const user = await requireUser(request);
|
||||
|
||||
const votingMonthYear = rangeToMonthYear(
|
||||
badRequestIfFalsy(nextNonCompletedVoting(new Date())),
|
||||
);
|
||||
const suggestions =
|
||||
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canSuggestNewUserBE({
|
||||
user,
|
||||
suggested,
|
||||
targetPlusTier: data.tier,
|
||||
suggestions,
|
||||
}),
|
||||
"No permissions to make this suggestion",
|
||||
);
|
||||
|
||||
await PlusSuggestionRepository.create({
|
||||
authorId: user.id,
|
||||
suggestedId: suggested.id,
|
||||
tier: data.tier,
|
||||
text: data.comment,
|
||||
...votingMonthYear,
|
||||
});
|
||||
|
||||
notify({
|
||||
userIds: [suggested.id],
|
||||
notification: {
|
||||
type: "PLUS_SUGGESTION_ADDED",
|
||||
meta: {
|
||||
tier: data.tier,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw redirect(plusSuggestionPage({ tier: data.tier }));
|
||||
};
|
||||
import { action } from "../actions/plus.suggestions.new.server";
|
||||
export { action };
|
||||
|
||||
export default function PlusNewSuggestionModalPage() {
|
||||
const user = useUser();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
import type {
|
||||
ActionFunction,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import type { ShouldRevalidateFunction } from "@remix-run/react";
|
||||
import { Link, Outlet, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { z } from "zod";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
|
|
@ -14,33 +9,26 @@ import { Catcher } from "~/components/Catcher";
|
|||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { RelativeTime } from "~/components/RelativeTime";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import { PLUS_TIERS } from "~/constants";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import type * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
isVotingActive,
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import {
|
||||
canAddCommentToSuggestionFE,
|
||||
canDeleteComment,
|
||||
canSuggestNewUserFE,
|
||||
isFirstSuggestion,
|
||||
} from "~/permissions";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import { _action, actualNumber } from "~/utils/zod";
|
||||
|
||||
import { action } from "../actions/plus.suggestions.server";
|
||||
import { loader } from "../loaders/plus.suggestions.server";
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
|
|
@ -52,97 +40,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
const suggestionActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("DELETE_COMMENT"),
|
||||
suggestionId: z.preprocess(actualNumber, z.number()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("DELETE_SUGGESTION_OF_THEMSELVES"),
|
||||
tier: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.min(Math.min(...PLUS_TIERS))
|
||||
.max(Math.max(...PLUS_TIERS)),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: suggestionActionSchema,
|
||||
});
|
||||
const user = await requireUser(request);
|
||||
|
||||
const votingMonthYear = rangeToMonthYear(
|
||||
badRequestIfFalsy(nextNonCompletedVoting(new Date())),
|
||||
);
|
||||
|
||||
switch (data._action) {
|
||||
case "DELETE_COMMENT": {
|
||||
const suggestions =
|
||||
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
|
||||
|
||||
const suggestionToDelete = suggestions.find((suggestion) =>
|
||||
suggestion.suggestions.some(
|
||||
(suggestion) => suggestion.id === data.suggestionId,
|
||||
),
|
||||
);
|
||||
invariant(suggestionToDelete);
|
||||
const subSuggestion = suggestionToDelete.suggestions.find(
|
||||
(suggestion) => suggestion.id === data.suggestionId,
|
||||
);
|
||||
invariant(subSuggestion);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canDeleteComment({
|
||||
user,
|
||||
author: subSuggestion.author,
|
||||
suggestionId: data.suggestionId,
|
||||
suggestions,
|
||||
}),
|
||||
"No permissions to delete this comment",
|
||||
);
|
||||
|
||||
const suggestionHasComments = suggestionToDelete.suggestions.length > 1;
|
||||
|
||||
if (
|
||||
suggestionHasComments &&
|
||||
isFirstSuggestion({ suggestionId: data.suggestionId, suggestions })
|
||||
) {
|
||||
// admin only action
|
||||
await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({
|
||||
tier: suggestionToDelete.tier,
|
||||
userId: suggestionToDelete.suggested.id,
|
||||
...votingMonthYear,
|
||||
});
|
||||
} else {
|
||||
await PlusSuggestionRepository.deleteById(data.suggestionId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DELETE_SUGGESTION_OF_THEMSELVES": {
|
||||
invariant(!isVotingActive(), "Voting is active");
|
||||
|
||||
await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({
|
||||
tier: data.tier,
|
||||
userId: user.id,
|
||||
...votingMonthYear,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export type PlusSuggestionsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = ({ formMethod }) => {
|
||||
|
|
@ -150,20 +47,6 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({ formMethod }) => {
|
|||
return Boolean(formMethod && formMethod !== "GET");
|
||||
};
|
||||
|
||||
export const loader = async () => {
|
||||
const nextVotingRange = nextNonCompletedVoting(new Date());
|
||||
|
||||
if (!nextVotingRange) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions: await PlusSuggestionRepository.findAllByMonth(
|
||||
rangeToMonthYear(nextVotingRange),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export default function PlusSuggestionsPage() {
|
||||
const data = useLoaderData<PlusSuggestionsLoaderData>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
|
|||
90
app/features/plus-voting/actions/plus.voting.server.ts
Normal file
90
app/features/plus-voting/actions/plus.voting.server.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { PLUS_UPVOTE } from "~/constants";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import type { PlusVoteFromFE } from "~/features/plus-voting/core";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { isVotingActive } from "~/features/plus-voting/core/voting-time";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { badRequestIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { votingActionSchema } from "../plus-voting-schemas";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: votingActionSchema,
|
||||
});
|
||||
|
||||
if (!isVotingActive()) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
invariant(user.plusTier, "User should have plusTier");
|
||||
|
||||
const usersForVoting = await PlusVotingRepository.usersForVoting({
|
||||
id: user.id,
|
||||
plusTier: user.plusTier,
|
||||
});
|
||||
|
||||
// this should not be needed but makes the voting a bit more resilient
|
||||
// if there is a bug that causes some user to show up twice, or some user to show up who should not be included
|
||||
const seen = new Set<number>();
|
||||
const filteredVotes = data.votes.filter((vote) => {
|
||||
if (seen.has(vote.votedId)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(vote.votedId);
|
||||
return usersForVoting.some((u) => u.user.id === vote.votedId);
|
||||
});
|
||||
|
||||
validateVotes({ votes: filteredVotes, usersForVoting });
|
||||
|
||||
// freebie +1 for yourself if you vote
|
||||
const votesForDb = [...filteredVotes].concat({
|
||||
votedId: user.id,
|
||||
score: PLUS_UPVOTE,
|
||||
});
|
||||
|
||||
const votingRange = badRequestIfFalsy(nextNonCompletedVoting(new Date()));
|
||||
const { month, year } = rangeToMonthYear(votingRange);
|
||||
await PlusVotingRepository.upsertMany(
|
||||
votesForDb.map((vote) => ({
|
||||
...vote,
|
||||
authorId: user.id,
|
||||
month,
|
||||
year,
|
||||
tier: user.plusTier!, // no clue why i couldn't make narrowing the type down above work
|
||||
validAfter: dateToDatabaseTimestamp(votingRange.endDate),
|
||||
})),
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function validateVotes({
|
||||
votes,
|
||||
usersForVoting,
|
||||
}: {
|
||||
votes: PlusVoteFromFE[];
|
||||
usersForVoting?: PlusVotingRepository.UsersForVoting;
|
||||
}) {
|
||||
if (!usersForVoting) throw new Response(null, { status: 400 });
|
||||
|
||||
// converting it to set also handles the check for duplicate ids
|
||||
const votedUserIds = new Set(votes.map((v) => v.votedId));
|
||||
|
||||
if (votedUserIds.size !== usersForVoting.length) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
for (const { user } of usersForVoting) {
|
||||
if (!votedUserIds.has(user.id)) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import type { UserWithPlusTier } from "~/db/tables";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import { lastCompletedVoting } from "~/features/plus-voting/core";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
const results = await PlusVotingRepository.resultsByMonthYear(
|
||||
lastCompletedVoting(new Date()),
|
||||
);
|
||||
|
||||
return {
|
||||
results: censorScores(results),
|
||||
ownScores: ownScores({ results, user }),
|
||||
lastCompletedVoting: lastCompletedVoting(new Date()),
|
||||
};
|
||||
};
|
||||
|
||||
function censorScores(results: PlusVotingRepository.ResultsByMonthYearItem[]) {
|
||||
return results.map((tier) => ({
|
||||
...tier,
|
||||
passed: tier.passed.map((result) => ({
|
||||
...result,
|
||||
score: undefined,
|
||||
})),
|
||||
failed: tier.failed.map((result) => ({
|
||||
...result,
|
||||
score: undefined,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function ownScores({
|
||||
results,
|
||||
user,
|
||||
}: {
|
||||
results: PlusVotingRepository.ResultsByMonthYearItem[];
|
||||
user?: Pick<UserWithPlusTier, "id" | "patronTier">;
|
||||
}) {
|
||||
return results
|
||||
.flatMap((tier) => [...tier.failed, ...tier.passed])
|
||||
.filter((result) => {
|
||||
return result.id === user?.id;
|
||||
})
|
||||
.map((result) => {
|
||||
const showScore =
|
||||
(result.wasSuggested && !result.passedVoting) ||
|
||||
isAtLeastFiveDollarTierPatreon(user);
|
||||
|
||||
const resultsOfOwnTierExcludingOwn = () => {
|
||||
const ownTierResults = results.find(
|
||||
(tier) => tier.tier === result.tier,
|
||||
);
|
||||
invariant(ownTierResults, "own tier results not found");
|
||||
|
||||
return [...ownTierResults.failed, ...ownTierResults.passed].filter(
|
||||
(otherResult) => otherResult.id !== result.id,
|
||||
);
|
||||
};
|
||||
|
||||
const mappedResult: {
|
||||
tier: number;
|
||||
score?: number;
|
||||
passedVoting: number;
|
||||
betterThan?: number;
|
||||
} = {
|
||||
tier: result.tier,
|
||||
score: databaseAvgToPercentage(result.score),
|
||||
passedVoting: result.passedVoting,
|
||||
betterThan: roundToNDecimalPlaces(
|
||||
(resultsOfOwnTierExcludingOwn().filter(
|
||||
(otherResult) => otherResult.score <= result.score,
|
||||
).length /
|
||||
resultsOfOwnTierExcludingOwn().length) *
|
||||
100,
|
||||
),
|
||||
};
|
||||
|
||||
if (!showScore) mappedResult.score = undefined;
|
||||
if (!isAtLeastFiveDollarTierPatreon(user) || !result.passedVoting) {
|
||||
mappedResult.betterThan = undefined;
|
||||
}
|
||||
|
||||
return mappedResult;
|
||||
});
|
||||
}
|
||||
|
||||
function databaseAvgToPercentage(score: number) {
|
||||
const scoreNormalized = score + 1;
|
||||
|
||||
return roundToNDecimalPlaces((scoreNormalized / 2) * 100);
|
||||
}
|
||||
96
app/features/plus-voting/loaders/plus.voting.server.ts
Normal file
96
app/features/plus-voting/loaders/plus.voting.server.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { isVotingActive } from "~/features/plus-voting/core/voting-time";
|
||||
|
||||
export type PlusVotingLoaderData =
|
||||
// next voting date is not in the system
|
||||
| {
|
||||
type: "noTimeDefinedInfo";
|
||||
}
|
||||
// voting is not active OR user is not eligible to vote
|
||||
| {
|
||||
type: "timeInfo";
|
||||
voted?: boolean;
|
||||
timeInfo: {
|
||||
timestamp: number;
|
||||
timing: "starts" | "ends";
|
||||
relativeTime: string;
|
||||
};
|
||||
}
|
||||
// user can vote
|
||||
| {
|
||||
type: "voting";
|
||||
usersForVoting: PlusVotingRepository.UsersForVoting;
|
||||
votingEnds: {
|
||||
timestamp: number;
|
||||
relativeTime: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const user = await getUser(request);
|
||||
|
||||
const now = new Date();
|
||||
const nextVotingRange = nextNonCompletedVoting(now);
|
||||
|
||||
if (!nextVotingRange) {
|
||||
return { type: "noTimeDefinedInfo" };
|
||||
}
|
||||
|
||||
if (!isVotingActive()) {
|
||||
return {
|
||||
type: "timeInfo",
|
||||
timeInfo: {
|
||||
relativeTime: formatDistance(nextVotingRange.startDate, now, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
timestamp: nextVotingRange.startDate.getTime(),
|
||||
timing: "starts",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const usersForVoting = user?.plusTier
|
||||
? await PlusVotingRepository.usersForVoting({
|
||||
id: user.id,
|
||||
plusTier: user.plusTier,
|
||||
})
|
||||
: undefined;
|
||||
const hasVoted = user
|
||||
? await PlusVotingRepository.hasVoted({
|
||||
authorId: user.id,
|
||||
...rangeToMonthYear(nextVotingRange),
|
||||
})
|
||||
: false;
|
||||
|
||||
if (!usersForVoting || hasVoted) {
|
||||
return {
|
||||
type: "timeInfo",
|
||||
voted: hasVoted,
|
||||
timeInfo: {
|
||||
relativeTime: formatDistance(nextVotingRange.endDate, now, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
timestamp: nextVotingRange.endDate.getTime(),
|
||||
timing: "ends",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "voting",
|
||||
usersForVoting,
|
||||
votingEnds: {
|
||||
timestamp: nextVotingRange.endDate.getTime(),
|
||||
relativeTime: formatDistance(nextVotingRange.endDate, now, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
16
app/features/plus-voting/plus-voting-schemas.ts
Normal file
16
app/features/plus-voting/plus-voting-schemas.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { z } from "zod";
|
||||
import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "~/constants";
|
||||
import type { PlusVoteFromFE } from "~/features/plus-voting/core";
|
||||
import { assertType } from "~/utils/types";
|
||||
import { safeJSONParse } from "~/utils/zod";
|
||||
|
||||
export const voteSchema = z.object({
|
||||
votedId: z.number(),
|
||||
score: z.number().refine((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)),
|
||||
});
|
||||
|
||||
assertType<z.infer<typeof voteSchema>, PlusVoteFromFE>();
|
||||
|
||||
export const votingActionSchema = z.object({
|
||||
votes: z.preprocess(safeJSONParse, z.array(voteSchema)),
|
||||
});
|
||||
|
|
@ -1,21 +1,13 @@
|
|||
import type {
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import { lastCompletedVoting } from "~/features/plus-voting/core";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { PLUS_SERVER_DISCORD_URL, userPage } from "~/utils/urls";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
|
||||
import { loader } from "../loaders/plus.voting.results.server";
|
||||
export { loader };
|
||||
|
||||
import "~/styles/plus-history.css";
|
||||
import type { UserWithPlusTier } from "~/db/tables";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
|
|
@ -27,94 +19,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
const results = await PlusVotingRepository.resultsByMonthYear(
|
||||
lastCompletedVoting(new Date()),
|
||||
);
|
||||
|
||||
return {
|
||||
results: censorScores(results),
|
||||
ownScores: ownScores({ results, user }),
|
||||
lastCompletedVoting: lastCompletedVoting(new Date()),
|
||||
};
|
||||
};
|
||||
|
||||
function databaseAvgToPercentage(score: number) {
|
||||
const scoreNormalized = score + 1;
|
||||
|
||||
return roundToNDecimalPlaces((scoreNormalized / 2) * 100);
|
||||
}
|
||||
|
||||
function censorScores(results: PlusVotingRepository.ResultsByMonthYearItem[]) {
|
||||
return results.map((tier) => ({
|
||||
...tier,
|
||||
passed: tier.passed.map((result) => ({
|
||||
...result,
|
||||
score: undefined,
|
||||
})),
|
||||
failed: tier.failed.map((result) => ({
|
||||
...result,
|
||||
score: undefined,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function ownScores({
|
||||
results,
|
||||
user,
|
||||
}: {
|
||||
results: PlusVotingRepository.ResultsByMonthYearItem[];
|
||||
user?: Pick<UserWithPlusTier, "id" | "patronTier">;
|
||||
}) {
|
||||
return results
|
||||
.flatMap((tier) => [...tier.failed, ...tier.passed])
|
||||
.filter((result) => {
|
||||
return result.id === user?.id;
|
||||
})
|
||||
.map((result) => {
|
||||
const showScore =
|
||||
(result.wasSuggested && !result.passedVoting) ||
|
||||
isAtLeastFiveDollarTierPatreon(user);
|
||||
|
||||
const resultsOfOwnTierExcludingOwn = () => {
|
||||
const ownTierResults = results.find(
|
||||
(tier) => tier.tier === result.tier,
|
||||
);
|
||||
invariant(ownTierResults, "own tier results not found");
|
||||
|
||||
return [...ownTierResults.failed, ...ownTierResults.passed].filter(
|
||||
(otherResult) => otherResult.id !== result.id,
|
||||
);
|
||||
};
|
||||
|
||||
const mappedResult: {
|
||||
tier: number;
|
||||
score?: number;
|
||||
passedVoting: number;
|
||||
betterThan?: number;
|
||||
} = {
|
||||
tier: result.tier,
|
||||
score: databaseAvgToPercentage(result.score),
|
||||
passedVoting: result.passedVoting,
|
||||
betterThan: roundToNDecimalPlaces(
|
||||
(resultsOfOwnTierExcludingOwn().filter(
|
||||
(otherResult) => otherResult.score <= result.score,
|
||||
).length /
|
||||
resultsOfOwnTierExcludingOwn().length) *
|
||||
100,
|
||||
),
|
||||
};
|
||||
|
||||
if (!showScore) mappedResult.score = undefined;
|
||||
if (!isAtLeastFiveDollarTierPatreon(user) || !result.passedVoting) {
|
||||
mappedResult.betterThan = undefined;
|
||||
}
|
||||
|
||||
return mappedResult;
|
||||
});
|
||||
}
|
||||
|
||||
export default function PlusVotingResultsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +1,22 @@
|
|||
import type {
|
||||
ActionFunction,
|
||||
LoaderFunction,
|
||||
MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { Form, useLoaderData } from "@remix-run/react";
|
||||
import { formatDistance } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { RelativeTime } from "~/components/RelativeTime";
|
||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||
import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "~/constants";
|
||||
import { getUser, requireUser } from "~/features/auth/core/user.server";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import type { PlusVoteFromFE } from "~/features/plus-voting/core";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
usePlusVoting,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { isVotingActive } from "~/features/plus-voting/core/voting-time";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { usePlusVoting } from "~/features/plus-voting/core";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { badRequestIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertType, assertUnreachable } from "~/utils/types";
|
||||
import { safeJSONParse } from "~/utils/zod";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { PlusSuggestionComments } from "../../plus-suggestions/routes/plus.suggestions";
|
||||
|
||||
import { action } from "../actions/plus.voting.server";
|
||||
import {
|
||||
type PlusVotingLoaderData,
|
||||
loader,
|
||||
} from "../loaders/plus.voting.server";
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Plus Server Voting",
|
||||
|
|
@ -36,180 +24,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
const voteSchema = z.object({
|
||||
votedId: z.number(),
|
||||
score: z.number().refine((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)),
|
||||
});
|
||||
|
||||
assertType<z.infer<typeof voteSchema>, PlusVoteFromFE>();
|
||||
|
||||
const votingActionSchema = z.object({
|
||||
votes: z.preprocess(safeJSONParse, z.array(voteSchema)),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: votingActionSchema,
|
||||
});
|
||||
|
||||
if (!isVotingActive()) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
invariant(user.plusTier, "User should have plusTier");
|
||||
|
||||
const usersForVoting = await PlusVotingRepository.usersForVoting({
|
||||
id: user.id,
|
||||
plusTier: user.plusTier,
|
||||
});
|
||||
|
||||
// this should not be needed but makes the voting a bit more resilient
|
||||
// if there is a bug that causes some user to show up twice, or some user to show up who should not be included
|
||||
const seen = new Set<number>();
|
||||
const filteredVotes = data.votes.filter((vote) => {
|
||||
if (seen.has(vote.votedId)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(vote.votedId);
|
||||
return usersForVoting.some((u) => u.user.id === vote.votedId);
|
||||
});
|
||||
|
||||
validateVotes({ votes: filteredVotes, usersForVoting });
|
||||
|
||||
// freebie +1 for yourself if you vote
|
||||
const votesForDb = [...filteredVotes].concat({
|
||||
votedId: user.id,
|
||||
score: PLUS_UPVOTE,
|
||||
});
|
||||
|
||||
const votingRange = badRequestIfFalsy(nextNonCompletedVoting(new Date()));
|
||||
const { month, year } = rangeToMonthYear(votingRange);
|
||||
await PlusVotingRepository.upsertMany(
|
||||
votesForDb.map((vote) => ({
|
||||
...vote,
|
||||
authorId: user.id,
|
||||
month,
|
||||
year,
|
||||
tier: user.plusTier!, // no clue why i couldn't make narrowing the type down above work
|
||||
validAfter: dateToDatabaseTimestamp(votingRange.endDate),
|
||||
})),
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function validateVotes({
|
||||
votes,
|
||||
usersForVoting,
|
||||
}: {
|
||||
votes: PlusVoteFromFE[];
|
||||
usersForVoting?: PlusVotingRepository.UsersForVoting;
|
||||
}) {
|
||||
if (!usersForVoting) throw new Response(null, { status: 400 });
|
||||
|
||||
// converting it to set also handles the check for duplicate ids
|
||||
const votedUserIds = new Set(votes.map((v) => v.votedId));
|
||||
|
||||
if (votedUserIds.size !== usersForVoting.length) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
for (const { user } of usersForVoting) {
|
||||
if (!votedUserIds.has(user.id)) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PlusVotingLoaderData =
|
||||
// next voting date is not in the system
|
||||
| {
|
||||
type: "noTimeDefinedInfo";
|
||||
}
|
||||
// voting is not active OR user is not eligible to vote
|
||||
| {
|
||||
type: "timeInfo";
|
||||
voted?: boolean;
|
||||
timeInfo: {
|
||||
timestamp: number;
|
||||
timing: "starts" | "ends";
|
||||
relativeTime: string;
|
||||
};
|
||||
}
|
||||
// user can vote
|
||||
| {
|
||||
type: "voting";
|
||||
usersForVoting: PlusVotingRepository.UsersForVoting;
|
||||
votingEnds: {
|
||||
timestamp: number;
|
||||
relativeTime: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ request }) => {
|
||||
const user = await getUser(request);
|
||||
|
||||
const now = new Date();
|
||||
const nextVotingRange = nextNonCompletedVoting(now);
|
||||
|
||||
if (!nextVotingRange) {
|
||||
return { type: "noTimeDefinedInfo" };
|
||||
}
|
||||
|
||||
if (!isVotingActive()) {
|
||||
return {
|
||||
type: "timeInfo",
|
||||
timeInfo: {
|
||||
relativeTime: formatDistance(nextVotingRange.startDate, now, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
timestamp: nextVotingRange.startDate.getTime(),
|
||||
timing: "starts",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const usersForVoting = user?.plusTier
|
||||
? await PlusVotingRepository.usersForVoting({
|
||||
id: user.id,
|
||||
plusTier: user.plusTier,
|
||||
})
|
||||
: undefined;
|
||||
const hasVoted = user
|
||||
? await PlusVotingRepository.hasVoted({
|
||||
authorId: user.id,
|
||||
...rangeToMonthYear(nextVotingRange),
|
||||
})
|
||||
: false;
|
||||
|
||||
if (!usersForVoting || hasVoted) {
|
||||
return {
|
||||
type: "timeInfo",
|
||||
voted: hasVoted,
|
||||
timeInfo: {
|
||||
relativeTime: formatDistance(nextVotingRange.endDate, now, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
timestamp: nextVotingRange.endDate.getTime(),
|
||||
timing: "ends",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "voting",
|
||||
usersForVoting,
|
||||
votingEnds: {
|
||||
timestamp: nextVotingRange.endDate.getTime(),
|
||||
relativeTime: formatDistance(nextVotingRange.endDate, now, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function PlusVotingPage() {
|
||||
const data = useLoaderData<PlusVotingLoaderData>();
|
||||
|
||||
|
|
|
|||
58
app/features/sendouq-settings/actions/q.settings.server.ts
Normal file
58
app/features/sendouq-settings/actions/q.settings.server.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
import { parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { settingsActionSchema } from "../q-settings-schemas.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: settingsActionSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "UPDATE_MAP_MODE_PREFERENCES": {
|
||||
await QSettingsRepository.updateUserMapModePreferences({
|
||||
mapModePreferences: data.mapModePreferences,
|
||||
userId: user.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_VC": {
|
||||
await QSettingsRepository.updateVoiceChat({
|
||||
userId: user.id,
|
||||
vc: data.vc,
|
||||
languages: data.languages,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_SENDOUQ_WEAPON_POOL": {
|
||||
await QSettingsRepository.updateSendouQWeaponPool({
|
||||
userId: user.id,
|
||||
weaponPool: data.weaponPool,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_NO_SCREEN": {
|
||||
await QSettingsRepository.updateNoScreen({
|
||||
userId: user.id,
|
||||
noScreen: Number(data.noScreen),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "REMOVE_TRUST": {
|
||||
await QSettingsRepository.deleteTrustedUser({
|
||||
trustGiverUserId: user.id,
|
||||
trustReceiverUserId: data.userToRemoveTrustFromId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
};
|
||||
13
app/features/sendouq-settings/loaders/q.settings.server.ts
Normal file
13
app/features/sendouq-settings/loaders/q.settings.server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
|
||||
return {
|
||||
settings: await QSettingsRepository.settingsByUserId(user.id),
|
||||
trusted: await QSettingsRepository.findTrustedUsersByGiverId(user.id),
|
||||
team: await QSettingsRepository.currentTeamByUserId(user.id),
|
||||
};
|
||||
};
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
import type {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { WeaponCombobox } from "~/components/Combobox";
|
||||
|
|
@ -16,6 +11,7 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
|
|||
import { ModeImage, WeaponImage } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { MapIcon } from "~/components/icons/Map";
|
||||
import { MicrophoneFilledIcon } from "~/components/icons/MicrophoneFilled";
|
||||
|
|
@ -24,20 +20,16 @@ import { SpeakerFilledIcon } from "~/components/icons/SpeakerFilled";
|
|||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import type { Preference, Tables, UserMapModePreferences } from "~/db/tables";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import {
|
||||
soundCodeToLocalStorageKey,
|
||||
soundVolume,
|
||||
} from "~/features/chat/chat-utils";
|
||||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
SENDOUQ_PAGE,
|
||||
|
|
@ -47,15 +39,17 @@ import {
|
|||
} from "~/utils/urls";
|
||||
import { BANNED_MAPS } from "../banned-maps";
|
||||
import { ModeMapPoolPicker } from "../components/ModeMapPoolPicker";
|
||||
import { PreferenceRadioGroup } from "../components/PreferenceRadioGroup";
|
||||
import {
|
||||
AMOUNT_OF_MAPS_IN_POOL_PER_MODE,
|
||||
SENDOUQ_WEAPON_POOL_MAX_SIZE,
|
||||
} from "../q-settings-constants";
|
||||
import { settingsActionSchema } from "../q-settings-schemas.server";
|
||||
|
||||
import { action } from "../actions/q.settings.server";
|
||||
import { loader } from "../loaders/q.settings.server";
|
||||
export { loader, action };
|
||||
|
||||
import "../q-settings.css";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { PreferenceRadioGroup } from "../components/PreferenceRadioGroup";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
|
|
@ -80,79 +74,15 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: settingsActionSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "UPDATE_MAP_MODE_PREFERENCES": {
|
||||
await QSettingsRepository.updateUserMapModePreferences({
|
||||
mapModePreferences: data.mapModePreferences,
|
||||
userId: user.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_VC": {
|
||||
await QSettingsRepository.updateVoiceChat({
|
||||
userId: user.id,
|
||||
vc: data.vc,
|
||||
languages: data.languages,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_SENDOUQ_WEAPON_POOL": {
|
||||
await QSettingsRepository.updateSendouQWeaponPool({
|
||||
userId: user.id,
|
||||
weaponPool: data.weaponPool,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_NO_SCREEN": {
|
||||
await QSettingsRepository.updateNoScreen({
|
||||
userId: user.id,
|
||||
noScreen: Number(data.noScreen),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "REMOVE_TRUST": {
|
||||
await QSettingsRepository.deleteTrustedUser({
|
||||
trustGiverUserId: user.id,
|
||||
trustReceiverUserId: data.userToRemoveTrustFromId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
|
||||
return {
|
||||
settings: await QSettingsRepository.settingsByUserId(user.id),
|
||||
trusted: await QSettingsRepository.findTrustedUsersByGiverId(user.id),
|
||||
team: await QSettingsRepository.currentTeamByUserId(user.id),
|
||||
};
|
||||
};
|
||||
|
||||
export default function SendouQSettingsPage() {
|
||||
return (
|
||||
<Main className="stack sm">
|
||||
<div className="stack">
|
||||
<MapPicker />
|
||||
<WeaponPool />
|
||||
<VoiceChat />
|
||||
<Sounds />
|
||||
<TrustedUsers />
|
||||
<Misc />
|
||||
</div>
|
||||
<Main>
|
||||
<MapPicker />
|
||||
<WeaponPool />
|
||||
<VoiceChat />
|
||||
<Sounds />
|
||||
<TrustedUsers />
|
||||
<Misc />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
7
app/features/sendouq-streams/loaders/q.streams.server.ts
Normal file
7
app/features/sendouq-streams/loaders/q.streams.server.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { cachedStreams } from "../core/streams.server";
|
||||
|
||||
export const loader = async () => {
|
||||
return {
|
||||
streams: await cachedStreams(),
|
||||
};
|
||||
};
|
||||
|
|
@ -12,7 +12,9 @@ import { databaseTimestampToDate } from "~/utils/dates";
|
|||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { FAQ_PAGE, sendouQMatchPage, twitchUrl, userPage } from "~/utils/urls";
|
||||
import { cachedStreams } from "../core/streams.server";
|
||||
|
||||
import { loader } from "../loaders/q.streams.server";
|
||||
export { loader };
|
||||
|
||||
import "~/features/sendouq/q.css";
|
||||
|
||||
|
|
@ -28,12 +30,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const loader = async () => {
|
||||
return {
|
||||
streams: await cachedStreams(),
|
||||
};
|
||||
};
|
||||
|
||||
export default function SendouQStreamsPage() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
366
app/features/sendouq/actions/q.looking.server.ts
Normal file
366
app/features/sendouq/actions/q.looking.server.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
|
||||
import { groupAfterMorph } from "../core/groups";
|
||||
import { membersNeededForFull } from "../core/groups.server";
|
||||
import { createMatchMemento, matchMapList } from "../core/match.server";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { lookingSchema } from "../q-schemas.server";
|
||||
import { addLike } from "../queries/addLike.server";
|
||||
import { addManagerRole } from "../queries/addManagerRole.server";
|
||||
import { chatCodeByGroupId } from "../queries/chatCodeByGroupId.server";
|
||||
import { createMatch } from "../queries/createMatch.server";
|
||||
import { deleteLike } from "../queries/deleteLike.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { groupHasMatch } from "../queries/groupHasMatch.server";
|
||||
import { groupSize } from "../queries/groupSize.server";
|
||||
import { groupSuccessorOwner } from "../queries/groupSuccessorOwner";
|
||||
import { leaveGroup } from "../queries/leaveGroup.server";
|
||||
import { likeExists } from "../queries/likeExists.server";
|
||||
import { morphGroups } from "../queries/morphGroups.server";
|
||||
import { refreshGroup } from "../queries/refreshGroup.server";
|
||||
import { removeManagerRole } from "../queries/removeManagerRole.server";
|
||||
import { updateNote } from "../queries/updateNote.server";
|
||||
|
||||
// this function doesn't throw normally because we are assuming
|
||||
// if there is a validation error the user saw stale data
|
||||
// and when we return null we just force a refresh
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: lookingSchema,
|
||||
});
|
||||
const currentGroup = findCurrentGroupByUserId(user.id);
|
||||
if (!currentGroup) return null;
|
||||
|
||||
// this throws because there should normally be no way user loses ownership by the action of some other user
|
||||
const validateIsGroupOwner = () =>
|
||||
errorToastIfFalsy(currentGroup.role === "OWNER", "Not owner");
|
||||
const isGroupManager = () =>
|
||||
currentGroup.role === "MANAGER" || currentGroup.role === "OWNER";
|
||||
|
||||
switch (data._action) {
|
||||
case "LIKE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
try {
|
||||
addLike({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) throw e;
|
||||
// the group disbanded before we could like it
|
||||
if (errorIsSqliteForeignKeyConstraintFailure(e)) return null;
|
||||
|
||||
throw e;
|
||||
}
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
const targetChatCode = chatCodeByGroupId(data.targetGroupId);
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "LIKE_RECEIVED",
|
||||
revalidateOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "RECHALLENGE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
await QRepository.rechallenge({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
|
||||
const targetChatCode = chatCodeByGroupId(data.targetGroupId);
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "LIKE_RECEIVED",
|
||||
revalidateOnly: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "UNLIKE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
deleteLike({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "GROUP_UP": {
|
||||
if (!isGroupManager()) return null;
|
||||
if (
|
||||
!likeExists({
|
||||
targetGroupId: currentGroup.id,
|
||||
likerGroupId: data.targetGroupId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookingGroups = await QRepository.findLookingGroups({
|
||||
maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)),
|
||||
ownGroupId: currentGroup.id,
|
||||
includeChatCode: true,
|
||||
});
|
||||
|
||||
const ourGroup = lookingGroups.find(
|
||||
(group) => group.id === currentGroup.id,
|
||||
);
|
||||
if (!ourGroup) return null;
|
||||
const theirGroup = lookingGroups.find(
|
||||
(group) => group.id === data.targetGroupId,
|
||||
);
|
||||
if (!theirGroup) return null;
|
||||
|
||||
const { id: survivingGroupId } = groupAfterMorph({
|
||||
liker: "THEM",
|
||||
ourGroup,
|
||||
theirGroup,
|
||||
});
|
||||
|
||||
const otherGroup =
|
||||
ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
|
||||
|
||||
invariant(ourGroup.members, "our group has no members");
|
||||
invariant(otherGroup.members, "other group has no members");
|
||||
|
||||
morphGroups({
|
||||
survivingGroupId,
|
||||
otherGroupId: otherGroup.id,
|
||||
newMembers: otherGroup.members.map((m) => m.id),
|
||||
});
|
||||
refreshGroup(survivingGroupId);
|
||||
|
||||
if (ourGroup.chatCode && theirGroup.chatCode) {
|
||||
ChatSystemMessage.send([
|
||||
{
|
||||
room: ourGroup.chatCode,
|
||||
type: "NEW_GROUP",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
{
|
||||
room: theirGroup.chatCode,
|
||||
type: "NEW_GROUP",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "MATCH_UP_RECHALLENGE":
|
||||
case "MATCH_UP": {
|
||||
if (!isGroupManager()) return null;
|
||||
if (
|
||||
!likeExists({
|
||||
targetGroupId: currentGroup.id,
|
||||
likerGroupId: data.targetGroupId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookingGroups = await QRepository.findLookingGroups({
|
||||
minGroupSize: FULL_GROUP_SIZE,
|
||||
ownGroupId: currentGroup.id,
|
||||
includeChatCode: true,
|
||||
});
|
||||
|
||||
const ourGroup = lookingGroups.find(
|
||||
(group) => group.id === currentGroup.id,
|
||||
);
|
||||
if (!ourGroup) return null;
|
||||
const theirGroup = lookingGroups.find(
|
||||
(group) => group.id === data.targetGroupId,
|
||||
);
|
||||
if (!theirGroup) return null;
|
||||
|
||||
errorToastIfFalsy(
|
||||
ourGroup.members.length === FULL_GROUP_SIZE,
|
||||
"Our group is not full",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
theirGroup.members.length === FULL_GROUP_SIZE,
|
||||
"Their group is not full",
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
!groupHasMatch(ourGroup.id),
|
||||
"Our group already has a match",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
!groupHasMatch(theirGroup.id),
|
||||
"Their group already has a match",
|
||||
);
|
||||
|
||||
const ourGroupPreferences = await QRepository.mapModePreferencesByGroupId(
|
||||
ourGroup.id,
|
||||
);
|
||||
const theirGroupPreferences =
|
||||
await QRepository.mapModePreferencesByGroupId(theirGroup.id);
|
||||
const mapList = matchMapList(
|
||||
{
|
||||
id: ourGroup.id,
|
||||
preferences: ourGroupPreferences,
|
||||
},
|
||||
{
|
||||
id: theirGroup.id,
|
||||
preferences: theirGroupPreferences,
|
||||
ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE",
|
||||
},
|
||||
);
|
||||
const createdMatch = createMatch({
|
||||
alphaGroupId: ourGroup.id,
|
||||
bravoGroupId: theirGroup.id,
|
||||
mapList,
|
||||
memento: createMatchMemento({
|
||||
own: { group: ourGroup, preferences: ourGroupPreferences },
|
||||
their: { group: theirGroup, preferences: theirGroupPreferences },
|
||||
mapList,
|
||||
}),
|
||||
});
|
||||
|
||||
if (ourGroup.chatCode && theirGroup.chatCode) {
|
||||
ChatSystemMessage.send([
|
||||
{
|
||||
room: ourGroup.chatCode,
|
||||
type: "MATCH_STARTED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
{
|
||||
room: theirGroup.chatCode,
|
||||
type: "MATCH_STARTED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
notify({
|
||||
userIds: [
|
||||
...ourGroup.members.map((m) => m.id),
|
||||
...theirGroup.members.map((m) => m.id),
|
||||
],
|
||||
defaultSeenUserIds: [user.id],
|
||||
notification: {
|
||||
type: "SQ_NEW_MATCH",
|
||||
meta: {
|
||||
matchId: createdMatch.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw redirect(sendouQMatchPage(createdMatch.id));
|
||||
}
|
||||
case "GIVE_MANAGER": {
|
||||
validateIsGroupOwner();
|
||||
|
||||
addManagerRole({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "REMOVE_MANAGER": {
|
||||
validateIsGroupOwner();
|
||||
|
||||
removeManagerRole({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "LEAVE_GROUP": {
|
||||
errorToastIfFalsy(
|
||||
!currentGroup.matchId,
|
||||
"Can't leave group while in a match",
|
||||
);
|
||||
let newOwnerId: number | null = null;
|
||||
if (currentGroup.role === "OWNER") {
|
||||
newOwnerId = groupSuccessorOwner(currentGroup.id);
|
||||
}
|
||||
|
||||
leaveGroup({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
newOwnerId,
|
||||
wasOwner: currentGroup.role === "OWNER",
|
||||
});
|
||||
|
||||
const targetChatCode = chatCodeByGroupId(currentGroup.id);
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "USER_LEFT",
|
||||
context: { name: user.username },
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(SENDOUQ_PAGE);
|
||||
}
|
||||
case "KICK_FROM_GROUP": {
|
||||
validateIsGroupOwner();
|
||||
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
|
||||
|
||||
leaveGroup({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
newOwnerId: null,
|
||||
wasOwner: false,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "REFRESH_GROUP": {
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "UPDATE_NOTE": {
|
||||
updateNote({
|
||||
note: data.value,
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "DELETE_PRIVATE_USER_NOTE": {
|
||||
await QRepository.deletePrivateUserNote({
|
||||
authorId: user.id,
|
||||
targetId: data.targetId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
306
app/features/sendouq/actions/q.match.$id.server.ts
Normal file
306
app/features/sendouq/actions/q.match.$id.server.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { sql } from "~/db/sql";
|
||||
import type { ReportedWeapon } from "~/db/tables";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import type { ChatMessage } from "~/features/chat/chat-types";
|
||||
import { currentOrPreviousSeason, currentSeason } from "~/features/mmr/season";
|
||||
import { refreshUserSkills } from "~/features/mmr/tiered.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { SENDOUQ_PREPARING_PAGE, sendouQMatchPage } from "~/utils/urls";
|
||||
import { compareMatchToReportedScores } from "../core/match.server";
|
||||
import { mergeReportedWeapons } from "../core/reported-weapons.server";
|
||||
import { calculateMatchSkills } from "../core/skills.server";
|
||||
import {
|
||||
summarizeMaps,
|
||||
summarizePlayerResults,
|
||||
} from "../core/summarizer.server";
|
||||
import { matchSchema, qMatchPageParamsSchema } from "../q-schemas.server";
|
||||
import { winnersArrayToWinner } from "../q-utils";
|
||||
import { addDummySkill } from "../queries/addDummySkill.server";
|
||||
import { addMapResults } from "../queries/addMapResults.server";
|
||||
import { addPlayerResults } from "../queries/addPlayerResults.server";
|
||||
import { addReportedWeapons } from "../queries/addReportedWeapons.server";
|
||||
import { addSkills } from "../queries/addSkills.server";
|
||||
import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findMatchById } from "../queries/findMatchById.server";
|
||||
import { reportScore } from "../queries/reportScore.server";
|
||||
import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
|
||||
import { setGroupAsInactive } from "../queries/setGroupAsInactive.server";
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const matchId = parseParams({
|
||||
params,
|
||||
schema: qMatchPageParamsSchema,
|
||||
}).id;
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: matchSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "REPORT_SCORE": {
|
||||
const reportWeapons = () => {
|
||||
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
|
||||
|
||||
const mergedWeapons = mergeReportedWeapons({
|
||||
oldWeapons: oldReportedWeapons,
|
||||
newWeapons: data.weapons as (ReportedWeapon & {
|
||||
mapIndex: number;
|
||||
groupMatchMapId: number;
|
||||
})[],
|
||||
newReportedMapsCount: data.winners.length,
|
||||
});
|
||||
|
||||
sql.transaction(() => {
|
||||
deleteReporterWeaponsByMatchId(matchId);
|
||||
addReportedWeapons(mergedWeapons);
|
||||
})();
|
||||
};
|
||||
|
||||
const match = notFoundIfFalsy(findMatchById(matchId));
|
||||
if (match.isLocked) {
|
||||
reportWeapons();
|
||||
return null;
|
||||
}
|
||||
|
||||
errorToastIfFalsy(
|
||||
!data.adminReport || isMod(user),
|
||||
"Only mods can report scores as admin",
|
||||
);
|
||||
const members = [
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.alphaGroupId,
|
||||
})),
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.bravoGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.bravoGroupId,
|
||||
})),
|
||||
];
|
||||
|
||||
const groupMemberOfId = members.find((m) => m.id === user.id)?.groupId;
|
||||
invariant(
|
||||
groupMemberOfId || data.adminReport,
|
||||
"User is not a member of any group",
|
||||
);
|
||||
|
||||
const winner = winnersArrayToWinner(data.winners);
|
||||
const winnerGroupId =
|
||||
winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId;
|
||||
const loserGroupId =
|
||||
winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId;
|
||||
|
||||
// when admin reports match gets locked right away
|
||||
const compared = data.adminReport
|
||||
? "SAME"
|
||||
: compareMatchToReportedScores({
|
||||
match,
|
||||
winners: data.winners,
|
||||
newReporterGroupId: groupMemberOfId!,
|
||||
previousReporterGroupId: match.reportedByUserId
|
||||
? members.find((m) => m.id === match.reportedByUserId)!.groupId
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// same group reporting same score, probably by mistake
|
||||
if (compared === "DUPLICATE") {
|
||||
reportWeapons();
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchIsBeingCanceled = data.winners.length === 0;
|
||||
|
||||
const { newSkills, differences } =
|
||||
compared === "SAME" && !matchIsBeingCanceled
|
||||
? calculateMatchSkills({
|
||||
groupMatchId: match.id,
|
||||
winner: (await QMatchRepository.findGroupById({
|
||||
groupId: winnerGroupId,
|
||||
}))!.members.map((m) => m.id),
|
||||
loser: (await QMatchRepository.findGroupById({
|
||||
groupId: loserGroupId,
|
||||
}))!.members.map((m) => m.id),
|
||||
winnerGroupId,
|
||||
loserGroupId,
|
||||
})
|
||||
: { newSkills: null, differences: null };
|
||||
|
||||
const shouldLockMatchWithoutChangingRecords =
|
||||
compared === "SAME" && matchIsBeingCanceled;
|
||||
|
||||
let clearCaches = false;
|
||||
sql.transaction(() => {
|
||||
if (
|
||||
compared === "FIX_PREVIOUS" ||
|
||||
compared === "FIRST_REPORT" ||
|
||||
data.adminReport
|
||||
) {
|
||||
reportScore({
|
||||
matchId,
|
||||
reportedByUserId: user.id,
|
||||
winners: data.winners,
|
||||
});
|
||||
}
|
||||
// own group gets set inactive
|
||||
if (groupMemberOfId) setGroupAsInactive(groupMemberOfId);
|
||||
// skills & map/player results only update after both teams have reported
|
||||
if (newSkills) {
|
||||
addMapResults(
|
||||
summarizeMaps({ match, members, winners: data.winners }),
|
||||
);
|
||||
addPlayerResults(
|
||||
summarizePlayerResults({ match, members, winners: data.winners }),
|
||||
);
|
||||
addSkills({
|
||||
skills: newSkills,
|
||||
differences,
|
||||
groupMatchId: match.id,
|
||||
oldMatchMemento: match.memento,
|
||||
});
|
||||
clearCaches = true;
|
||||
}
|
||||
if (shouldLockMatchWithoutChangingRecords) {
|
||||
addDummySkill(match.id);
|
||||
clearCaches = true;
|
||||
}
|
||||
// fix edge case where they 1) report score 2) report weapons 3) report score again, but with different amount of maps played
|
||||
if (compared === "FIX_PREVIOUS") {
|
||||
deleteReporterWeaponsByMatchId(matchId);
|
||||
}
|
||||
// admin reporting, just set both groups inactive
|
||||
if (data.adminReport) {
|
||||
setGroupAsInactive(match.alphaGroupId);
|
||||
setGroupAsInactive(match.bravoGroupId);
|
||||
}
|
||||
})();
|
||||
|
||||
if (clearCaches) {
|
||||
// this is kind of useless to do when admin reports since skills don't change
|
||||
// but it's not the most common case so it's ok
|
||||
try {
|
||||
refreshUserSkills(currentOrPreviousSeason(new Date())!.nth);
|
||||
} catch (error) {
|
||||
logger.warn("Error refreshing user skills", error);
|
||||
}
|
||||
|
||||
refreshStreamsCache();
|
||||
}
|
||||
|
||||
if (compared === "DIFFERENT") {
|
||||
return {
|
||||
error: matchIsBeingCanceled
|
||||
? ("cant-cancel" as const)
|
||||
: ("different" as const),
|
||||
};
|
||||
}
|
||||
|
||||
// in a different transaction but it's okay
|
||||
reportWeapons();
|
||||
|
||||
if (match.chatCode) {
|
||||
const type = (): NonNullable<ChatMessage["type"]> => {
|
||||
if (compared === "SAME") {
|
||||
return matchIsBeingCanceled
|
||||
? "CANCEL_CONFIRMED"
|
||||
: "SCORE_CONFIRMED";
|
||||
}
|
||||
|
||||
return matchIsBeingCanceled ? "CANCEL_REPORTED" : "SCORE_REPORTED";
|
||||
};
|
||||
|
||||
ChatSystemMessage.send({
|
||||
room: match.chatCode,
|
||||
type: type(),
|
||||
context: {
|
||||
name: user.username,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "LOOK_AGAIN": {
|
||||
const season = currentSeason(new Date());
|
||||
errorToastIfFalsy(season, "Season is not active");
|
||||
|
||||
const previousGroup = await QMatchRepository.findGroupById({
|
||||
groupId: data.previousGroupId,
|
||||
});
|
||||
errorToastIfFalsy(previousGroup, "Previous group not found");
|
||||
|
||||
for (const member of previousGroup.members) {
|
||||
const currentGroup = findCurrentGroupByUserId(member.id);
|
||||
errorToastIfFalsy(!currentGroup, "Member is already in a group");
|
||||
if (member.id === user.id) {
|
||||
errorToastIfFalsy(
|
||||
member.role === "OWNER",
|
||||
"You are not the owner of the group",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await QRepository.createGroupFromPrevious({
|
||||
previousGroupId: data.previousGroupId,
|
||||
members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })),
|
||||
});
|
||||
|
||||
throw redirect(SENDOUQ_PREPARING_PAGE);
|
||||
}
|
||||
case "REPORT_WEAPONS": {
|
||||
const match = notFoundIfFalsy(findMatchById(matchId));
|
||||
errorToastIfFalsy(match.reportedAt, "Match has not been reported yet");
|
||||
|
||||
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
|
||||
|
||||
const mergedWeapons = mergeReportedWeapons({
|
||||
oldWeapons: oldReportedWeapons,
|
||||
newWeapons: data.weapons as (ReportedWeapon & {
|
||||
mapIndex: number;
|
||||
groupMatchMapId: number;
|
||||
})[],
|
||||
});
|
||||
|
||||
sql.transaction(() => {
|
||||
deleteReporterWeaponsByMatchId(matchId);
|
||||
addReportedWeapons(mergedWeapons);
|
||||
})();
|
||||
|
||||
break;
|
||||
}
|
||||
case "ADD_PRIVATE_USER_NOTE": {
|
||||
await QRepository.upsertPrivateUserNote({
|
||||
authorId: user.id,
|
||||
sentiment: data.sentiment,
|
||||
targetId: data.targetId,
|
||||
text: data.comment,
|
||||
});
|
||||
|
||||
throw redirect(sendouQMatchPage(matchId));
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
99
app/features/sendouq/actions/q.preparing.server.ts
Normal file
99
app/features/sendouq/actions/q.preparing.server.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { ActionFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { currentSeason } from "~/features/mmr/season";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
|
||||
import { hasGroupManagerPerms } from "../core/groups";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { preparingSchema } from "../q-schemas.server";
|
||||
import { addMember } from "../queries/addMember.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { refreshGroup } from "../queries/refreshGroup.server";
|
||||
import { setGroupAsActive } from "../queries/setGroupAsActive.server";
|
||||
|
||||
export type SendouQPreparingAction = typeof action;
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: preparingSchema,
|
||||
});
|
||||
|
||||
const currentGroup = findCurrentGroupByUserId(user.id);
|
||||
errorToastIfFalsy(currentGroup, "No group found");
|
||||
|
||||
if (!hasGroupManagerPerms(currentGroup.role)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const season = currentSeason(new Date());
|
||||
errorToastIfFalsy(season, "Season is not active");
|
||||
|
||||
switch (data._action) {
|
||||
case "JOIN_QUEUE": {
|
||||
if (currentGroup.status !== "PREPARING") {
|
||||
return null;
|
||||
}
|
||||
|
||||
setGroupAsActive(currentGroup.id);
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
return redirect(SENDOUQ_LOOKING_PAGE);
|
||||
}
|
||||
case "ADD_TRUSTED": {
|
||||
const available = await QRepository.findActiveGroupMembers();
|
||||
if (available.some(({ userId }) => userId === data.id)) {
|
||||
return { error: "taken" } as const;
|
||||
}
|
||||
|
||||
errorToastIfFalsy(
|
||||
(await QRepository.usersThatTrusted(user.id)).trusters.some(
|
||||
(trusterUser) => trusterUser.id === data.id,
|
||||
),
|
||||
"Not trusted",
|
||||
);
|
||||
|
||||
const ownGroupWithMembers = await QMatchRepository.findGroupById({
|
||||
groupId: currentGroup.id,
|
||||
});
|
||||
invariant(ownGroupWithMembers, "No own group found");
|
||||
errorToastIfFalsy(
|
||||
ownGroupWithMembers.members.length < FULL_GROUP_SIZE,
|
||||
"Group is full",
|
||||
);
|
||||
|
||||
addMember({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.id,
|
||||
role: "MANAGER",
|
||||
});
|
||||
|
||||
await QRepository.refreshTrust({
|
||||
trustGiverUserId: data.id,
|
||||
trustReceiverUserId: user.id,
|
||||
});
|
||||
|
||||
notify({
|
||||
userIds: [data.id],
|
||||
notification: {
|
||||
type: "SQ_ADDED_TO_GROUP",
|
||||
meta: {
|
||||
adderUsername: user.username,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
113
app/features/sendouq/actions/q.server.ts
Normal file
113
app/features/sendouq/actions/q.server.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { sql } from "~/db/sql";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { currentSeason } from "~/features/mmr/season";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { giveTrust } from "~/features/tournament/queries/giveTrust.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PREPARING_PAGE } from "~/utils/urls";
|
||||
import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
|
||||
import { frontPageSchema } from "../q-schemas.server";
|
||||
import { userCanJoinQueueAt } from "../q-utils";
|
||||
import { addMember } from "../queries/addMember.server";
|
||||
import { deleteLikesByGroupId } from "../queries/deleteLikesByGroupId.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: frontPageSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "JOIN_QUEUE": {
|
||||
await validateCanJoinQ(user);
|
||||
|
||||
await QRepository.createGroup({
|
||||
status: data.direct === "true" ? "ACTIVE" : "PREPARING",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return redirect(
|
||||
data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE,
|
||||
);
|
||||
}
|
||||
case "JOIN_TEAM_WITH_TRUST":
|
||||
case "JOIN_TEAM": {
|
||||
await validateCanJoinQ(user);
|
||||
|
||||
const code = new URL(request.url).searchParams.get(
|
||||
JOIN_CODE_SEARCH_PARAM_KEY,
|
||||
);
|
||||
|
||||
const groupInvitedTo =
|
||||
code && user ? findGroupByInviteCode(code) : undefined;
|
||||
errorToastIfFalsy(
|
||||
groupInvitedTo,
|
||||
"Invite code doesn't match any active team",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
groupInvitedTo.members.length < FULL_GROUP_SIZE,
|
||||
"Team is full",
|
||||
);
|
||||
|
||||
sql.transaction(() => {
|
||||
addMember({
|
||||
groupId: groupInvitedTo.id,
|
||||
userId: user.id,
|
||||
role: "MANAGER",
|
||||
});
|
||||
deleteLikesByGroupId(groupInvitedTo.id);
|
||||
|
||||
if (data._action === "JOIN_TEAM_WITH_TRUST") {
|
||||
const owner = groupInvitedTo.members.find((m) => m.role === "OWNER");
|
||||
invariant(owner, "Owner not found");
|
||||
|
||||
giveTrust({
|
||||
trustGiverUserId: user.id,
|
||||
trustReceiverUserId: owner.id,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return redirect(
|
||||
groupInvitedTo.status === "PREPARING"
|
||||
? SENDOUQ_PREPARING_PAGE
|
||||
: SENDOUQ_LOOKING_PAGE,
|
||||
);
|
||||
}
|
||||
case "ADD_FRIEND_CODE": {
|
||||
errorToastIfFalsy(
|
||||
!(await UserRepository.currentFriendCodeByUserId(user.id)),
|
||||
"Friend code already set",
|
||||
);
|
||||
|
||||
await UserRepository.insertFriendCode({
|
||||
userId: user.id,
|
||||
friendCode: data.friendCode,
|
||||
submitterUserId: user.id,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function validateCanJoinQ(user: { id: number; discordId: string }) {
|
||||
const friendCode = await UserRepository.currentFriendCodeByUserId(user.id);
|
||||
errorToastIfFalsy(friendCode, "No friend code");
|
||||
const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW";
|
||||
|
||||
errorToastIfFalsy(currentSeason(new Date()), "Season is not active");
|
||||
errorToastIfFalsy(!findCurrentGroupByUserId(user.id), "Already in a group");
|
||||
errorToastIfFalsy(canJoinQueue, "Can't join queue right now");
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
SENDOU_INK_BASE_URL,
|
||||
sendouQInviteLink,
|
||||
} from "~/utils/urls";
|
||||
import type { SendouQPreparingAction } from "../routes/q.preparing";
|
||||
import type { SendouQPreparingAction } from "../actions/q.preparing.server";
|
||||
|
||||
export function MemberAdder({
|
||||
inviteCode,
|
||||
|
|
|
|||
133
app/features/sendouq/loaders/q.looking.server.ts
Normal file
133
app/features/sendouq/loaders/q.looking.server.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import { hasGroupManagerPerms } from "../core/groups";
|
||||
import {
|
||||
addFutureMatchModes,
|
||||
addNoScreenIndicator,
|
||||
addReplayIndicator,
|
||||
addSkillRangeToGroups,
|
||||
addSkillsToGroups,
|
||||
censorGroups,
|
||||
censorGroupsIfOwnExpired,
|
||||
divideGroups,
|
||||
groupExpiryStatus,
|
||||
membersNeededForFull,
|
||||
sortGroupsBySkillAndSentiment,
|
||||
} from "../core/groups.server";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findLikes } from "../queries/findLikes";
|
||||
import { findRecentMatchPlayersByUserId } from "../queries/findRecentMatchPlayersByUserId.server";
|
||||
import { groupSize } from "../queries/groupSize.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
|
||||
const isPreview = Boolean(
|
||||
new URL(request.url).searchParams.get("preview") === "true" &&
|
||||
user &&
|
||||
isAtLeastFiveDollarTierPatreon(user),
|
||||
);
|
||||
|
||||
const currentGroup =
|
||||
user && !isPreview ? findCurrentGroupByUserId(user.id) : undefined;
|
||||
const redirectLocation = isPreview
|
||||
? undefined
|
||||
: groupRedirectLocationByCurrentLocation({
|
||||
group: currentGroup,
|
||||
currentLocation: "looking",
|
||||
});
|
||||
|
||||
if (redirectLocation) {
|
||||
throw redirect(redirectLocation);
|
||||
}
|
||||
|
||||
invariant(currentGroup || isPreview, "currentGroup is undefined");
|
||||
|
||||
const currentGroupSize = currentGroup ? groupSize(currentGroup.id) : 1;
|
||||
const groupIsFull = currentGroupSize === FULL_GROUP_SIZE;
|
||||
|
||||
const dividedGroups = divideGroups({
|
||||
groups: await QRepository.findLookingGroups({
|
||||
maxGroupSize:
|
||||
groupIsFull || isPreview
|
||||
? undefined
|
||||
: membersNeededForFull(currentGroupSize),
|
||||
minGroupSize: groupIsFull && !isPreview ? FULL_GROUP_SIZE : undefined,
|
||||
ownGroupId: currentGroup?.id,
|
||||
includeMapModePreferences: Boolean(groupIsFull || isPreview),
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
ownGroupId: currentGroup?.id,
|
||||
likes: currentGroup ? findLikes(currentGroup.id) : [],
|
||||
});
|
||||
|
||||
const season = currentOrPreviousSeason(new Date());
|
||||
|
||||
const {
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
isAccurateTiers,
|
||||
} = userSkills(season!.nth);
|
||||
const groupsWithSkills = addSkillsToGroups({
|
||||
groups: dividedGroups,
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
});
|
||||
|
||||
const groupsWithFutureMatchModes = addFutureMatchModes(groupsWithSkills);
|
||||
|
||||
const groupsWithNoScreenIndicator = addNoScreenIndicator(
|
||||
groupsWithFutureMatchModes,
|
||||
);
|
||||
|
||||
const groupsWithReplayIndicator = groupIsFull
|
||||
? addReplayIndicator({
|
||||
groups: groupsWithNoScreenIndicator,
|
||||
recentMatchPlayers: findRecentMatchPlayersByUserId(user!.id),
|
||||
userId: user!.id,
|
||||
})
|
||||
: groupsWithNoScreenIndicator;
|
||||
|
||||
const censoredGroups = censorGroups({
|
||||
groups: groupsWithReplayIndicator,
|
||||
showInviteCode: currentGroup
|
||||
? hasGroupManagerPerms(currentGroup.role) && !groupIsFull
|
||||
: false,
|
||||
});
|
||||
|
||||
const rangedGroups = addSkillRangeToGroups({
|
||||
groups: censoredGroups,
|
||||
hasLeviathan: isAccurateTiers,
|
||||
isPreview,
|
||||
});
|
||||
|
||||
const sortedGroups = sortGroupsBySkillAndSentiment({
|
||||
groups: rangedGroups,
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const expiryStatus = groupExpiryStatus(currentGroup);
|
||||
|
||||
return {
|
||||
groups: censorGroupsIfOwnExpired({
|
||||
groups: sortedGroups,
|
||||
ownGroupExpiryStatus: expiryStatus,
|
||||
}),
|
||||
role: currentGroup ? currentGroup.role : ("PREVIEWER" as const),
|
||||
chatCode: currentGroup?.chatCode,
|
||||
lastUpdated: new Date().getTime(),
|
||||
streamsCount: (await cachedStreams()).length,
|
||||
expiryStatus: groupExpiryStatus(currentGroup),
|
||||
};
|
||||
};
|
||||
116
app/features/sendouq/loaders/q.match.$id.server.ts
Normal file
116
app/features/sendouq/loaders/q.match.$id.server.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import cachified from "@epic-web/cachified";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import { isMod } from "~/permissions";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
import { reportedWeaponsToArrayOfArrays } from "../core/reported-weapons.server";
|
||||
import { qMatchPageParamsSchema } from "../q-schemas.server";
|
||||
import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const matchId = parseParams({
|
||||
params,
|
||||
schema: qMatchPageParamsSchema,
|
||||
}).id;
|
||||
const match = notFoundIfFalsy(await QMatchRepository.findById(matchId));
|
||||
|
||||
const [groupAlpha, groupBravo] = await Promise.all([
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.bravoGroupId,
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
]);
|
||||
invariant(groupAlpha, "Group alpha not found");
|
||||
invariant(groupBravo, "Group bravo not found");
|
||||
|
||||
const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id);
|
||||
const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id);
|
||||
const isMatchInsider = isTeamAlphaMember || isTeamBravoMember || isMod(user);
|
||||
const matchHappenedInTheLastMonth =
|
||||
databaseTimestampToDate(match.createdAt).getTime() >
|
||||
Date.now() - 30 * 24 * 3600 * 1000;
|
||||
|
||||
const censoredGroupAlpha = {
|
||||
...groupAlpha,
|
||||
chatCode: undefined,
|
||||
members: groupAlpha.members.map((m) => ({
|
||||
...m,
|
||||
friendCode:
|
||||
isMatchInsider && matchHappenedInTheLastMonth
|
||||
? m.friendCode
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
const censoredGroupBravo = {
|
||||
...groupBravo,
|
||||
chatCode: undefined,
|
||||
members: groupBravo.members.map((m) => ({
|
||||
...m,
|
||||
friendCode:
|
||||
isMatchInsider && matchHappenedInTheLastMonth
|
||||
? m.friendCode
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
const censoredMatch = { ...match, chatCode: undefined };
|
||||
|
||||
const groupChatCode = () => {
|
||||
if (isTeamAlphaMember) return groupAlpha.chatCode;
|
||||
if (isTeamBravoMember) return groupBravo.chatCode;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const rawReportedWeapons = match.reportedAt
|
||||
? reportedWeaponsByMatchId(matchId)
|
||||
: null;
|
||||
|
||||
const banScreen = !match.isLocked
|
||||
? await cachified({
|
||||
key: `matches-screen-ban-${match.id}`,
|
||||
cache,
|
||||
async getFreshValue() {
|
||||
const noScreenSettings =
|
||||
await QMatchRepository.groupMembersNoScreenSettings([
|
||||
groupAlpha,
|
||||
groupBravo,
|
||||
]);
|
||||
|
||||
return noScreenSettings.some((user) => user.noScreen);
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
match: censoredMatch,
|
||||
matchChatCode: isMatchInsider ? match.chatCode : null,
|
||||
canPostChatMessages: isTeamAlphaMember || isTeamBravoMember,
|
||||
groupChatCode: groupChatCode(),
|
||||
groupAlpha: censoredGroupAlpha,
|
||||
groupBravo: censoredGroupBravo,
|
||||
banScreen,
|
||||
groupMemberOf: isTeamAlphaMember
|
||||
? ("ALPHA" as const)
|
||||
: isTeamBravoMember
|
||||
? ("BRAVO" as const)
|
||||
: null,
|
||||
reportedWeapons: match.reportedAt
|
||||
? reportedWeaponsToArrayOfArrays({
|
||||
groupAlpha,
|
||||
groupBravo,
|
||||
mapList: match.mapList,
|
||||
reportedWeapons: rawReportedWeapons,
|
||||
})
|
||||
: null,
|
||||
rawReportedWeapons,
|
||||
};
|
||||
};
|
||||
30
app/features/sendouq/loaders/q.preparing.server.ts
Normal file
30
app/features/sendouq/loaders/q.preparing.server.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findPreparingGroup } from "../queries/findPreparingGroup.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
|
||||
const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined;
|
||||
const redirectLocation = groupRedirectLocationByCurrentLocation({
|
||||
group: currentGroup,
|
||||
currentLocation: "preparing",
|
||||
});
|
||||
|
||||
if (redirectLocation) {
|
||||
throw redirect(redirectLocation);
|
||||
}
|
||||
|
||||
const ownGroup = findPreparingGroup(currentGroup!.id);
|
||||
invariant(ownGroup, "No own group found");
|
||||
|
||||
return {
|
||||
lastUpdated: new Date().getTime(),
|
||||
group: ownGroup,
|
||||
role: currentGroup!.role,
|
||||
};
|
||||
};
|
||||
41
app/features/sendouq/loaders/q.server.ts
Normal file
41
app/features/sendouq/loaders/q.server.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getUserId } from "~/features/auth/core/user.server";
|
||||
import { currentSeason, nextSeason } from "~/features/mmr/season";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
|
||||
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
|
||||
const code = new URL(request.url).searchParams.get(
|
||||
JOIN_CODE_SEARCH_PARAM_KEY,
|
||||
);
|
||||
|
||||
const redirectLocation = groupRedirectLocationByCurrentLocation({
|
||||
group: user ? findCurrentGroupByUserId(user.id) : undefined,
|
||||
currentLocation: "default",
|
||||
});
|
||||
|
||||
if (redirectLocation) {
|
||||
throw redirect(`${redirectLocation}${code ? "?joining=true" : ""}`);
|
||||
}
|
||||
|
||||
const groupInvitedTo = code && user ? findGroupByInviteCode(code) : undefined;
|
||||
|
||||
const now = new Date();
|
||||
const season = currentSeason(now);
|
||||
const upcomingSeason = !season ? nextSeason(now) : undefined;
|
||||
|
||||
return {
|
||||
season,
|
||||
upcomingSeason,
|
||||
groupInvitedTo,
|
||||
friendCode: user
|
||||
? await UserRepository.currentFriendCodeByUserId(user.id)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
11
app/features/sendouq/loaders/tiers.server.ts
Normal file
11
app/features/sendouq/loaders/tiers.server.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
|
||||
export const loader = () => {
|
||||
const season = currentOrPreviousSeason(new Date());
|
||||
const { intervals } = userSkills(season!.nth);
|
||||
|
||||
return {
|
||||
intervals,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,9 +1,4 @@
|
|||
import type {
|
||||
ActionFunction,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { useFetcher, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
|
@ -16,74 +11,28 @@ import { Main } from "~/components/Main";
|
|||
import { NewTabs } from "~/components/NewTabs";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { getUser, requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import { Chat, useChat } from "~/features/chat/components/Chat";
|
||||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_SETTINGS_PAGE,
|
||||
SENDOUQ_STREAMS_PAGE,
|
||||
navIconUrl,
|
||||
sendouQMatchPage,
|
||||
} from "~/utils/urls";
|
||||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import { GroupCard } from "../components/GroupCard";
|
||||
import { GroupLeaver } from "../components/GroupLeaver";
|
||||
import { MemberAdder } from "../components/MemberAdder";
|
||||
import { groupAfterMorph, hasGroupManagerPerms } from "../core/groups";
|
||||
import {
|
||||
addFutureMatchModes,
|
||||
addNoScreenIndicator,
|
||||
addReplayIndicator,
|
||||
addSkillRangeToGroups,
|
||||
addSkillsToGroups,
|
||||
censorGroups,
|
||||
censorGroupsIfOwnExpired,
|
||||
divideGroups,
|
||||
groupExpiryStatus,
|
||||
membersNeededForFull,
|
||||
sortGroupsBySkillAndSentiment,
|
||||
} from "../core/groups.server";
|
||||
import { createMatchMemento, matchMapList } from "../core/match.server";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { lookingSchema } from "../q-schemas.server";
|
||||
import type { LookingGroupWithInviteCode } from "../q-types";
|
||||
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
||||
import { addLike } from "../queries/addLike.server";
|
||||
import { addManagerRole } from "../queries/addManagerRole.server";
|
||||
import { chatCodeByGroupId } from "../queries/chatCodeByGroupId.server";
|
||||
import { createMatch } from "../queries/createMatch.server";
|
||||
import { deleteLike } from "../queries/deleteLike.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findLikes } from "../queries/findLikes";
|
||||
import { findRecentMatchPlayersByUserId } from "../queries/findRecentMatchPlayersByUserId.server";
|
||||
import { groupHasMatch } from "../queries/groupHasMatch.server";
|
||||
import { groupSize } from "../queries/groupSize.server";
|
||||
import { groupSuccessorOwner } from "../queries/groupSuccessorOwner";
|
||||
import { leaveGroup } from "../queries/leaveGroup.server";
|
||||
import { likeExists } from "../queries/likeExists.server";
|
||||
import { morphGroups } from "../queries/morphGroups.server";
|
||||
import { refreshGroup } from "../queries/refreshGroup.server";
|
||||
import { removeManagerRole } from "../queries/removeManagerRole.server";
|
||||
import { updateNote } from "../queries/updateNote.server";
|
||||
|
||||
import { action } from "../actions/q.looking.server";
|
||||
import { loader } from "../loaders/q.looking.server";
|
||||
export { action, loader };
|
||||
|
||||
import "../q.css";
|
||||
|
||||
|
|
@ -103,445 +52,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
// this function doesn't throw normally because we are assuming
|
||||
// if there is a validation error the user saw stale data
|
||||
// and when we return null we just force a refresh
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: lookingSchema,
|
||||
});
|
||||
const currentGroup = findCurrentGroupByUserId(user.id);
|
||||
if (!currentGroup) return null;
|
||||
|
||||
// this throws because there should normally be no way user loses ownership by the action of some other user
|
||||
const validateIsGroupOwner = () =>
|
||||
errorToastIfFalsy(currentGroup.role === "OWNER", "Not owner");
|
||||
const isGroupManager = () =>
|
||||
currentGroup.role === "MANAGER" || currentGroup.role === "OWNER";
|
||||
|
||||
switch (data._action) {
|
||||
case "LIKE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
try {
|
||||
addLike({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) throw e;
|
||||
// the group disbanded before we could like it
|
||||
if (errorIsSqliteForeignKeyConstraintFailure(e)) return null;
|
||||
|
||||
throw e;
|
||||
}
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
const targetChatCode = chatCodeByGroupId(data.targetGroupId);
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "LIKE_RECEIVED",
|
||||
revalidateOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "RECHALLENGE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
await QRepository.rechallenge({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
|
||||
const targetChatCode = chatCodeByGroupId(data.targetGroupId);
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "LIKE_RECEIVED",
|
||||
revalidateOnly: true,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "UNLIKE": {
|
||||
if (!isGroupManager()) return null;
|
||||
|
||||
deleteLike({
|
||||
likerGroupId: currentGroup.id,
|
||||
targetGroupId: data.targetGroupId,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "GROUP_UP": {
|
||||
if (!isGroupManager()) return null;
|
||||
if (
|
||||
!likeExists({
|
||||
targetGroupId: currentGroup.id,
|
||||
likerGroupId: data.targetGroupId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookingGroups = await QRepository.findLookingGroups({
|
||||
maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)),
|
||||
ownGroupId: currentGroup.id,
|
||||
includeChatCode: true,
|
||||
});
|
||||
|
||||
const ourGroup = lookingGroups.find(
|
||||
(group) => group.id === currentGroup.id,
|
||||
);
|
||||
if (!ourGroup) return null;
|
||||
const theirGroup = lookingGroups.find(
|
||||
(group) => group.id === data.targetGroupId,
|
||||
);
|
||||
if (!theirGroup) return null;
|
||||
|
||||
const { id: survivingGroupId } = groupAfterMorph({
|
||||
liker: "THEM",
|
||||
ourGroup,
|
||||
theirGroup,
|
||||
});
|
||||
|
||||
const otherGroup =
|
||||
ourGroup.id === survivingGroupId ? theirGroup : ourGroup;
|
||||
|
||||
invariant(ourGroup.members, "our group has no members");
|
||||
invariant(otherGroup.members, "other group has no members");
|
||||
|
||||
morphGroups({
|
||||
survivingGroupId,
|
||||
otherGroupId: otherGroup.id,
|
||||
newMembers: otherGroup.members.map((m) => m.id),
|
||||
});
|
||||
refreshGroup(survivingGroupId);
|
||||
|
||||
if (ourGroup.chatCode && theirGroup.chatCode) {
|
||||
ChatSystemMessage.send([
|
||||
{
|
||||
room: ourGroup.chatCode,
|
||||
type: "NEW_GROUP",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
{
|
||||
room: theirGroup.chatCode,
|
||||
type: "NEW_GROUP",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "MATCH_UP_RECHALLENGE":
|
||||
case "MATCH_UP": {
|
||||
if (!isGroupManager()) return null;
|
||||
if (
|
||||
!likeExists({
|
||||
targetGroupId: currentGroup.id,
|
||||
likerGroupId: data.targetGroupId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookingGroups = await QRepository.findLookingGroups({
|
||||
minGroupSize: FULL_GROUP_SIZE,
|
||||
ownGroupId: currentGroup.id,
|
||||
includeChatCode: true,
|
||||
});
|
||||
|
||||
const ourGroup = lookingGroups.find(
|
||||
(group) => group.id === currentGroup.id,
|
||||
);
|
||||
if (!ourGroup) return null;
|
||||
const theirGroup = lookingGroups.find(
|
||||
(group) => group.id === data.targetGroupId,
|
||||
);
|
||||
if (!theirGroup) return null;
|
||||
|
||||
errorToastIfFalsy(
|
||||
ourGroup.members.length === FULL_GROUP_SIZE,
|
||||
"Our group is not full",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
theirGroup.members.length === FULL_GROUP_SIZE,
|
||||
"Their group is not full",
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
!groupHasMatch(ourGroup.id),
|
||||
"Our group already has a match",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
!groupHasMatch(theirGroup.id),
|
||||
"Their group already has a match",
|
||||
);
|
||||
|
||||
const ourGroupPreferences = await QRepository.mapModePreferencesByGroupId(
|
||||
ourGroup.id,
|
||||
);
|
||||
const theirGroupPreferences =
|
||||
await QRepository.mapModePreferencesByGroupId(theirGroup.id);
|
||||
const mapList = matchMapList(
|
||||
{
|
||||
id: ourGroup.id,
|
||||
preferences: ourGroupPreferences,
|
||||
},
|
||||
{
|
||||
id: theirGroup.id,
|
||||
preferences: theirGroupPreferences,
|
||||
ignoreModePreferences: data._action === "MATCH_UP_RECHALLENGE",
|
||||
},
|
||||
);
|
||||
const createdMatch = createMatch({
|
||||
alphaGroupId: ourGroup.id,
|
||||
bravoGroupId: theirGroup.id,
|
||||
mapList,
|
||||
memento: createMatchMemento({
|
||||
own: { group: ourGroup, preferences: ourGroupPreferences },
|
||||
their: { group: theirGroup, preferences: theirGroupPreferences },
|
||||
mapList,
|
||||
}),
|
||||
});
|
||||
|
||||
if (ourGroup.chatCode && theirGroup.chatCode) {
|
||||
ChatSystemMessage.send([
|
||||
{
|
||||
room: ourGroup.chatCode,
|
||||
type: "MATCH_STARTED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
{
|
||||
room: theirGroup.chatCode,
|
||||
type: "MATCH_STARTED",
|
||||
revalidateOnly: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
notify({
|
||||
userIds: [
|
||||
...ourGroup.members.map((m) => m.id),
|
||||
...theirGroup.members.map((m) => m.id),
|
||||
],
|
||||
defaultSeenUserIds: [user.id],
|
||||
notification: {
|
||||
type: "SQ_NEW_MATCH",
|
||||
meta: {
|
||||
matchId: createdMatch.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
throw redirect(sendouQMatchPage(createdMatch.id));
|
||||
}
|
||||
case "GIVE_MANAGER": {
|
||||
validateIsGroupOwner();
|
||||
|
||||
addManagerRole({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "REMOVE_MANAGER": {
|
||||
validateIsGroupOwner();
|
||||
|
||||
removeManagerRole({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "LEAVE_GROUP": {
|
||||
errorToastIfFalsy(
|
||||
!currentGroup.matchId,
|
||||
"Can't leave group while in a match",
|
||||
);
|
||||
let newOwnerId: number | null = null;
|
||||
if (currentGroup.role === "OWNER") {
|
||||
newOwnerId = groupSuccessorOwner(currentGroup.id);
|
||||
}
|
||||
|
||||
leaveGroup({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
newOwnerId,
|
||||
wasOwner: currentGroup.role === "OWNER",
|
||||
});
|
||||
|
||||
const targetChatCode = chatCodeByGroupId(currentGroup.id);
|
||||
if (targetChatCode) {
|
||||
ChatSystemMessage.send({
|
||||
room: targetChatCode,
|
||||
type: "USER_LEFT",
|
||||
context: { name: user.username },
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(SENDOUQ_PAGE);
|
||||
}
|
||||
case "KICK_FROM_GROUP": {
|
||||
validateIsGroupOwner();
|
||||
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
|
||||
|
||||
leaveGroup({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.userId,
|
||||
newOwnerId: null,
|
||||
wasOwner: false,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "REFRESH_GROUP": {
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "UPDATE_NOTE": {
|
||||
updateNote({
|
||||
note: data.value,
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
});
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
break;
|
||||
}
|
||||
case "DELETE_PRIVATE_USER_NOTE": {
|
||||
await QRepository.deletePrivateUserNote({
|
||||
authorId: user.id,
|
||||
targetId: data.targetId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
|
||||
const isPreview = Boolean(
|
||||
new URL(request.url).searchParams.get("preview") === "true" &&
|
||||
user &&
|
||||
isAtLeastFiveDollarTierPatreon(user),
|
||||
);
|
||||
|
||||
const currentGroup =
|
||||
user && !isPreview ? findCurrentGroupByUserId(user.id) : undefined;
|
||||
const redirectLocation = isPreview
|
||||
? undefined
|
||||
: groupRedirectLocationByCurrentLocation({
|
||||
group: currentGroup,
|
||||
currentLocation: "looking",
|
||||
});
|
||||
|
||||
if (redirectLocation) {
|
||||
throw redirect(redirectLocation);
|
||||
}
|
||||
|
||||
invariant(currentGroup || isPreview, "currentGroup is undefined");
|
||||
|
||||
const currentGroupSize = currentGroup ? groupSize(currentGroup.id) : 1;
|
||||
const groupIsFull = currentGroupSize === FULL_GROUP_SIZE;
|
||||
|
||||
const dividedGroups = divideGroups({
|
||||
groups: await QRepository.findLookingGroups({
|
||||
maxGroupSize:
|
||||
groupIsFull || isPreview
|
||||
? undefined
|
||||
: membersNeededForFull(currentGroupSize),
|
||||
minGroupSize: groupIsFull && !isPreview ? FULL_GROUP_SIZE : undefined,
|
||||
ownGroupId: currentGroup?.id,
|
||||
includeMapModePreferences: Boolean(groupIsFull || isPreview),
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
ownGroupId: currentGroup?.id,
|
||||
likes: currentGroup ? findLikes(currentGroup.id) : [],
|
||||
});
|
||||
|
||||
const season = currentOrPreviousSeason(new Date());
|
||||
|
||||
const {
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
isAccurateTiers,
|
||||
} = userSkills(season!.nth);
|
||||
const groupsWithSkills = addSkillsToGroups({
|
||||
groups: dividedGroups,
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
});
|
||||
|
||||
const groupsWithFutureMatchModes = addFutureMatchModes(groupsWithSkills);
|
||||
|
||||
const groupsWithNoScreenIndicator = addNoScreenIndicator(
|
||||
groupsWithFutureMatchModes,
|
||||
);
|
||||
|
||||
const groupsWithReplayIndicator = groupIsFull
|
||||
? addReplayIndicator({
|
||||
groups: groupsWithNoScreenIndicator,
|
||||
recentMatchPlayers: findRecentMatchPlayersByUserId(user!.id),
|
||||
userId: user!.id,
|
||||
})
|
||||
: groupsWithNoScreenIndicator;
|
||||
|
||||
const censoredGroups = censorGroups({
|
||||
groups: groupsWithReplayIndicator,
|
||||
showInviteCode: currentGroup
|
||||
? hasGroupManagerPerms(currentGroup.role) && !groupIsFull
|
||||
: false,
|
||||
});
|
||||
|
||||
const rangedGroups = addSkillRangeToGroups({
|
||||
groups: censoredGroups,
|
||||
hasLeviathan: isAccurateTiers,
|
||||
isPreview,
|
||||
});
|
||||
|
||||
const sortedGroups = sortGroupsBySkillAndSentiment({
|
||||
groups: rangedGroups,
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const expiryStatus = groupExpiryStatus(currentGroup);
|
||||
|
||||
return {
|
||||
groups: censorGroupsIfOwnExpired({
|
||||
groups: sortedGroups,
|
||||
ownGroupExpiryStatus: expiryStatus,
|
||||
}),
|
||||
role: currentGroup ? currentGroup.role : ("PREVIEWER" as const),
|
||||
chatCode: currentGroup?.chatCode,
|
||||
lastUpdated: new Date().getTime(),
|
||||
streamsCount: (await cachedStreams()).length,
|
||||
expiryStatus: groupExpiryStatus(currentGroup),
|
||||
};
|
||||
};
|
||||
|
||||
export default function QLookingPage() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,4 @@
|
|||
import cachified from "@epic-web/cachified";
|
||||
import type {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import type { FetcherWithComponents } from "@remix-run/react";
|
||||
import {
|
||||
Link,
|
||||
|
|
@ -30,23 +23,17 @@ import { NewTabs } from "~/components/NewTabs";
|
|||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { DiscordIcon } from "~/components/icons/Discord";
|
||||
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
|
||||
import { ScaleIcon } from "~/components/icons/Scale";
|
||||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { getUserId, requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import type { ChatMessage } from "~/features/chat/chat-types";
|
||||
import { Chat, type ChatProps, useChat } from "~/features/chat/components/Chat";
|
||||
import { currentOrPreviousSeason, currentSeason } from "~/features/mmr/season";
|
||||
import { refreshUserSkills } from "~/features/mmr/tiered.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import { currentSeason } from "~/features/mmr/season";
|
||||
import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPrivateNoteDialog";
|
||||
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
|
|
@ -54,25 +41,17 @@ import type { MainWeaponId } from "~/modules/in-game-lists";
|
|||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { isMod } from "~/permissions";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { animate } from "~/utils/flip";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { safeNumberParse } from "~/utils/number";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_PREPARING_PAGE,
|
||||
SENDOUQ_RULES_PAGE,
|
||||
SENDOU_INK_DISCORD_URL,
|
||||
navIconUrl,
|
||||
|
|
@ -84,36 +63,15 @@ import {
|
|||
} from "~/utils/urls";
|
||||
import { GroupCard } from "../components/GroupCard";
|
||||
import { matchEndedAtIndex } from "../core/match";
|
||||
import { compareMatchToReportedScores } from "../core/match.server";
|
||||
import type { ReportedWeaponForMerging } from "../core/reported-weapons.server";
|
||||
import {
|
||||
mergeReportedWeapons,
|
||||
reportedWeaponsToArrayOfArrays,
|
||||
} from "../core/reported-weapons.server";
|
||||
import { calculateMatchSkills } from "../core/skills.server";
|
||||
import {
|
||||
summarizeMaps,
|
||||
summarizePlayerResults,
|
||||
} from "../core/summarizer.server";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { useRecentlyReportedWeapons } from "../q-hooks";
|
||||
import { matchSchema, qMatchPageParamsSchema } from "../q-schemas.server";
|
||||
import { winnersArrayToWinner } from "../q-utils";
|
||||
import { addDummySkill } from "../queries/addDummySkill.server";
|
||||
import { addMapResults } from "../queries/addMapResults.server";
|
||||
import { addPlayerResults } from "../queries/addPlayerResults.server";
|
||||
import { addReportedWeapons } from "../queries/addReportedWeapons.server";
|
||||
import { addSkills } from "../queries/addSkills.server";
|
||||
import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findMatchById } from "../queries/findMatchById.server";
|
||||
import { reportScore } from "../queries/reportScore.server";
|
||||
import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
|
||||
import { setGroupAsInactive } from "../queries/setGroupAsInactive.server";
|
||||
|
||||
import { action } from "../actions/q.match.$id.server";
|
||||
import { loader } from "../loaders/q.match.$id.server";
|
||||
export { loader, action };
|
||||
|
||||
import "../q.css";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import type { ReportedWeapon, Tables } from "~/db/tables";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader> | null;
|
||||
|
|
@ -140,373 +98,6 @@ export const handle: SendouRouteHandle = {
|
|||
}),
|
||||
};
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const matchId = parseParams({
|
||||
params,
|
||||
schema: qMatchPageParamsSchema,
|
||||
}).id;
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: matchSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "REPORT_SCORE": {
|
||||
const reportWeapons = () => {
|
||||
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
|
||||
|
||||
const mergedWeapons = mergeReportedWeapons({
|
||||
oldWeapons: oldReportedWeapons,
|
||||
newWeapons: data.weapons as (ReportedWeapon & {
|
||||
mapIndex: number;
|
||||
groupMatchMapId: number;
|
||||
})[],
|
||||
newReportedMapsCount: data.winners.length,
|
||||
});
|
||||
|
||||
sql.transaction(() => {
|
||||
deleteReporterWeaponsByMatchId(matchId);
|
||||
addReportedWeapons(mergedWeapons);
|
||||
})();
|
||||
};
|
||||
|
||||
const match = notFoundIfFalsy(findMatchById(matchId));
|
||||
if (match.isLocked) {
|
||||
reportWeapons();
|
||||
return null;
|
||||
}
|
||||
|
||||
errorToastIfFalsy(
|
||||
!data.adminReport || isMod(user),
|
||||
"Only mods can report scores as admin",
|
||||
);
|
||||
const members = [
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.alphaGroupId,
|
||||
})),
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.bravoGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.bravoGroupId,
|
||||
})),
|
||||
];
|
||||
|
||||
const groupMemberOfId = members.find((m) => m.id === user.id)?.groupId;
|
||||
invariant(
|
||||
groupMemberOfId || data.adminReport,
|
||||
"User is not a member of any group",
|
||||
);
|
||||
|
||||
const winner = winnersArrayToWinner(data.winners);
|
||||
const winnerGroupId =
|
||||
winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId;
|
||||
const loserGroupId =
|
||||
winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId;
|
||||
|
||||
// when admin reports match gets locked right away
|
||||
const compared = data.adminReport
|
||||
? "SAME"
|
||||
: compareMatchToReportedScores({
|
||||
match,
|
||||
winners: data.winners,
|
||||
newReporterGroupId: groupMemberOfId!,
|
||||
previousReporterGroupId: match.reportedByUserId
|
||||
? members.find((m) => m.id === match.reportedByUserId)!.groupId
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// same group reporting same score, probably by mistake
|
||||
if (compared === "DUPLICATE") {
|
||||
reportWeapons();
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchIsBeingCanceled = data.winners.length === 0;
|
||||
|
||||
const { newSkills, differences } =
|
||||
compared === "SAME" && !matchIsBeingCanceled
|
||||
? calculateMatchSkills({
|
||||
groupMatchId: match.id,
|
||||
winner: (await QMatchRepository.findGroupById({
|
||||
groupId: winnerGroupId,
|
||||
}))!.members.map((m) => m.id),
|
||||
loser: (await QMatchRepository.findGroupById({
|
||||
groupId: loserGroupId,
|
||||
}))!.members.map((m) => m.id),
|
||||
winnerGroupId,
|
||||
loserGroupId,
|
||||
})
|
||||
: { newSkills: null, differences: null };
|
||||
|
||||
const shouldLockMatchWithoutChangingRecords =
|
||||
compared === "SAME" && matchIsBeingCanceled;
|
||||
|
||||
let clearCaches = false;
|
||||
sql.transaction(() => {
|
||||
if (
|
||||
compared === "FIX_PREVIOUS" ||
|
||||
compared === "FIRST_REPORT" ||
|
||||
data.adminReport
|
||||
) {
|
||||
reportScore({
|
||||
matchId,
|
||||
reportedByUserId: user.id,
|
||||
winners: data.winners,
|
||||
});
|
||||
}
|
||||
// own group gets set inactive
|
||||
if (groupMemberOfId) setGroupAsInactive(groupMemberOfId);
|
||||
// skills & map/player results only update after both teams have reported
|
||||
if (newSkills) {
|
||||
addMapResults(
|
||||
summarizeMaps({ match, members, winners: data.winners }),
|
||||
);
|
||||
addPlayerResults(
|
||||
summarizePlayerResults({ match, members, winners: data.winners }),
|
||||
);
|
||||
addSkills({
|
||||
skills: newSkills,
|
||||
differences,
|
||||
groupMatchId: match.id,
|
||||
oldMatchMemento: match.memento,
|
||||
});
|
||||
clearCaches = true;
|
||||
}
|
||||
if (shouldLockMatchWithoutChangingRecords) {
|
||||
addDummySkill(match.id);
|
||||
clearCaches = true;
|
||||
}
|
||||
// fix edge case where they 1) report score 2) report weapons 3) report score again, but with different amount of maps played
|
||||
if (compared === "FIX_PREVIOUS") {
|
||||
deleteReporterWeaponsByMatchId(matchId);
|
||||
}
|
||||
// admin reporting, just set both groups inactive
|
||||
if (data.adminReport) {
|
||||
setGroupAsInactive(match.alphaGroupId);
|
||||
setGroupAsInactive(match.bravoGroupId);
|
||||
}
|
||||
})();
|
||||
|
||||
if (clearCaches) {
|
||||
// this is kind of useless to do when admin reports since skills don't change
|
||||
// but it's not the most common case so it's ok
|
||||
try {
|
||||
refreshUserSkills(currentOrPreviousSeason(new Date())!.nth);
|
||||
} catch (error) {
|
||||
logger.warn("Error refreshing user skills", error);
|
||||
}
|
||||
|
||||
refreshStreamsCache();
|
||||
}
|
||||
|
||||
if (compared === "DIFFERENT") {
|
||||
return {
|
||||
error: matchIsBeingCanceled
|
||||
? ("cant-cancel" as const)
|
||||
: ("different" as const),
|
||||
};
|
||||
}
|
||||
|
||||
// in a different transaction but it's okay
|
||||
reportWeapons();
|
||||
|
||||
if (match.chatCode) {
|
||||
const type = (): NonNullable<ChatMessage["type"]> => {
|
||||
if (compared === "SAME") {
|
||||
return matchIsBeingCanceled
|
||||
? "CANCEL_CONFIRMED"
|
||||
: "SCORE_CONFIRMED";
|
||||
}
|
||||
|
||||
return matchIsBeingCanceled ? "CANCEL_REPORTED" : "SCORE_REPORTED";
|
||||
};
|
||||
|
||||
ChatSystemMessage.send({
|
||||
room: match.chatCode,
|
||||
type: type(),
|
||||
context: {
|
||||
name: user.username,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "LOOK_AGAIN": {
|
||||
const season = currentSeason(new Date());
|
||||
errorToastIfFalsy(season, "Season is not active");
|
||||
|
||||
const previousGroup = await QMatchRepository.findGroupById({
|
||||
groupId: data.previousGroupId,
|
||||
});
|
||||
errorToastIfFalsy(previousGroup, "Previous group not found");
|
||||
|
||||
for (const member of previousGroup.members) {
|
||||
const currentGroup = findCurrentGroupByUserId(member.id);
|
||||
errorToastIfFalsy(!currentGroup, "Member is already in a group");
|
||||
if (member.id === user.id) {
|
||||
errorToastIfFalsy(
|
||||
member.role === "OWNER",
|
||||
"You are not the owner of the group",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await QRepository.createGroupFromPrevious({
|
||||
previousGroupId: data.previousGroupId,
|
||||
members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })),
|
||||
});
|
||||
|
||||
throw redirect(SENDOUQ_PREPARING_PAGE);
|
||||
}
|
||||
case "REPORT_WEAPONS": {
|
||||
const match = notFoundIfFalsy(findMatchById(matchId));
|
||||
errorToastIfFalsy(match.reportedAt, "Match has not been reported yet");
|
||||
|
||||
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
|
||||
|
||||
const mergedWeapons = mergeReportedWeapons({
|
||||
oldWeapons: oldReportedWeapons,
|
||||
newWeapons: data.weapons as (ReportedWeapon & {
|
||||
mapIndex: number;
|
||||
groupMatchMapId: number;
|
||||
})[],
|
||||
});
|
||||
|
||||
sql.transaction(() => {
|
||||
deleteReporterWeaponsByMatchId(matchId);
|
||||
addReportedWeapons(mergedWeapons);
|
||||
})();
|
||||
|
||||
break;
|
||||
}
|
||||
case "ADD_PRIVATE_USER_NOTE": {
|
||||
await QRepository.upsertPrivateUserNote({
|
||||
authorId: user.id,
|
||||
sentiment: data.sentiment,
|
||||
targetId: data.targetId,
|
||||
text: data.comment,
|
||||
});
|
||||
|
||||
throw redirect(sendouQMatchPage(matchId));
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const matchId = parseParams({
|
||||
params,
|
||||
schema: qMatchPageParamsSchema,
|
||||
}).id;
|
||||
const match = notFoundIfFalsy(await QMatchRepository.findById(matchId));
|
||||
|
||||
const [groupAlpha, groupBravo] = await Promise.all([
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.bravoGroupId,
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
]);
|
||||
invariant(groupAlpha, "Group alpha not found");
|
||||
invariant(groupBravo, "Group bravo not found");
|
||||
|
||||
const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id);
|
||||
const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id);
|
||||
const isMatchInsider = isTeamAlphaMember || isTeamBravoMember || isMod(user);
|
||||
const matchHappenedInTheLastMonth =
|
||||
databaseTimestampToDate(match.createdAt).getTime() >
|
||||
Date.now() - 30 * 24 * 3600 * 1000;
|
||||
|
||||
const censoredGroupAlpha = {
|
||||
...groupAlpha,
|
||||
chatCode: undefined,
|
||||
members: groupAlpha.members.map((m) => ({
|
||||
...m,
|
||||
friendCode:
|
||||
isMatchInsider && matchHappenedInTheLastMonth
|
||||
? m.friendCode
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
const censoredGroupBravo = {
|
||||
...groupBravo,
|
||||
chatCode: undefined,
|
||||
members: groupBravo.members.map((m) => ({
|
||||
...m,
|
||||
friendCode:
|
||||
isMatchInsider && matchHappenedInTheLastMonth
|
||||
? m.friendCode
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
const censoredMatch = { ...match, chatCode: undefined };
|
||||
|
||||
const groupChatCode = () => {
|
||||
if (isTeamAlphaMember) return groupAlpha.chatCode;
|
||||
if (isTeamBravoMember) return groupBravo.chatCode;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const rawReportedWeapons = match.reportedAt
|
||||
? reportedWeaponsByMatchId(matchId)
|
||||
: null;
|
||||
|
||||
const banScreen = !match.isLocked
|
||||
? await cachified({
|
||||
key: `matches-screen-ban-${match.id}`,
|
||||
cache,
|
||||
async getFreshValue() {
|
||||
const noScreenSettings =
|
||||
await QMatchRepository.groupMembersNoScreenSettings([
|
||||
groupAlpha,
|
||||
groupBravo,
|
||||
]);
|
||||
|
||||
return noScreenSettings.some((user) => user.noScreen);
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
match: censoredMatch,
|
||||
matchChatCode: isMatchInsider ? match.chatCode : null,
|
||||
canPostChatMessages: isTeamAlphaMember || isTeamBravoMember,
|
||||
groupChatCode: groupChatCode(),
|
||||
groupAlpha: censoredGroupAlpha,
|
||||
groupBravo: censoredGroupBravo,
|
||||
banScreen,
|
||||
groupMemberOf: isTeamAlphaMember
|
||||
? ("ALPHA" as const)
|
||||
: isTeamBravoMember
|
||||
? ("BRAVO" as const)
|
||||
: null,
|
||||
reportedWeapons: match.reportedAt
|
||||
? reportedWeaponsToArrayOfArrays({
|
||||
groupAlpha,
|
||||
groupBravo,
|
||||
mapList: match.mapList,
|
||||
reportedWeapons: rawReportedWeapons,
|
||||
})
|
||||
: null,
|
||||
rawReportedWeapons,
|
||||
};
|
||||
};
|
||||
|
||||
export default function QMatchPage() {
|
||||
const user = useUser();
|
||||
const isMounted = useIsMounted();
|
||||
|
|
|
|||
|
|
@ -1,41 +1,21 @@
|
|||
import type {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { getUser, requireUser } from "~/features/auth/core/user.server";
|
||||
import { currentSeason } from "~/features/mmr/season";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
SENDOUQ_PREPARING_PAGE,
|
||||
navIconUrl,
|
||||
} from "~/utils/urls";
|
||||
import { SENDOUQ_PREPARING_PAGE, navIconUrl } from "~/utils/urls";
|
||||
import { GroupCard } from "../components/GroupCard";
|
||||
import { GroupLeaver } from "../components/GroupLeaver";
|
||||
import { MemberAdder } from "../components/MemberAdder";
|
||||
import { hasGroupManagerPerms } from "../core/groups";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { preparingSchema } from "../q-schemas.server";
|
||||
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
||||
import { addMember } from "../queries/addMember.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findPreparingGroup } from "../queries/findPreparingGroup.server";
|
||||
import { refreshGroup } from "../queries/refreshGroup.server";
|
||||
import { setGroupAsActive } from "../queries/setGroupAsActive.server";
|
||||
|
||||
import { action } from "../actions/q.preparing.server";
|
||||
import { loader } from "../loaders/q.preparing.server";
|
||||
export { loader, action };
|
||||
|
||||
import "../q.css";
|
||||
|
||||
|
|
@ -55,110 +35,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
export type SendouQPreparingAction = typeof action;
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: preparingSchema,
|
||||
});
|
||||
|
||||
const currentGroup = findCurrentGroupByUserId(user.id);
|
||||
errorToastIfFalsy(currentGroup, "No group found");
|
||||
|
||||
if (!hasGroupManagerPerms(currentGroup.role)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const season = currentSeason(new Date());
|
||||
errorToastIfFalsy(season, "Season is not active");
|
||||
|
||||
switch (data._action) {
|
||||
case "JOIN_QUEUE": {
|
||||
if (currentGroup.status !== "PREPARING") {
|
||||
return null;
|
||||
}
|
||||
|
||||
setGroupAsActive(currentGroup.id);
|
||||
refreshGroup(currentGroup.id);
|
||||
|
||||
return redirect(SENDOUQ_LOOKING_PAGE);
|
||||
}
|
||||
case "ADD_TRUSTED": {
|
||||
const available = await QRepository.findActiveGroupMembers();
|
||||
if (available.some(({ userId }) => userId === data.id)) {
|
||||
return { error: "taken" } as const;
|
||||
}
|
||||
|
||||
errorToastIfFalsy(
|
||||
(await QRepository.usersThatTrusted(user.id)).trusters.some(
|
||||
(trusterUser) => trusterUser.id === data.id,
|
||||
),
|
||||
"Not trusted",
|
||||
);
|
||||
|
||||
const ownGroupWithMembers = await QMatchRepository.findGroupById({
|
||||
groupId: currentGroup.id,
|
||||
});
|
||||
invariant(ownGroupWithMembers, "No own group found");
|
||||
errorToastIfFalsy(
|
||||
ownGroupWithMembers.members.length < FULL_GROUP_SIZE,
|
||||
"Group is full",
|
||||
);
|
||||
|
||||
addMember({
|
||||
groupId: currentGroup.id,
|
||||
userId: data.id,
|
||||
role: "MANAGER",
|
||||
});
|
||||
|
||||
await QRepository.refreshTrust({
|
||||
trustGiverUserId: data.id,
|
||||
trustReceiverUserId: user.id,
|
||||
});
|
||||
|
||||
notify({
|
||||
userIds: [data.id],
|
||||
notification: {
|
||||
type: "SQ_ADDED_TO_GROUP",
|
||||
meta: {
|
||||
adderUsername: user.username,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
|
||||
const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined;
|
||||
const redirectLocation = groupRedirectLocationByCurrentLocation({
|
||||
group: currentGroup,
|
||||
currentLocation: "preparing",
|
||||
});
|
||||
|
||||
if (redirectLocation) {
|
||||
throw redirect(redirectLocation);
|
||||
}
|
||||
|
||||
const ownGroup = findPreparingGroup(currentGroup!.id);
|
||||
invariant(ownGroup, "No own group found");
|
||||
|
||||
return {
|
||||
lastUpdated: new Date().getTime(),
|
||||
group: ownGroup,
|
||||
role: currentGroup!.role,
|
||||
};
|
||||
};
|
||||
|
||||
export default function QPreparingPage() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import type {
|
||||
ActionFunction,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
|
@ -20,32 +14,21 @@ import { Main } from "~/components/Main";
|
|||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserIcon } from "~/components/icons/User";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { getUserId, requireUser } from "~/features/auth/core/user.server";
|
||||
import type { RankingSeason } from "~/features/mmr/season";
|
||||
import { currentSeason, nextSeason } from "~/features/mmr/season";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { giveTrust } from "~/features/tournament/queries/giveTrust.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
LEADERBOARDS_PAGE,
|
||||
LOG_IN_URL,
|
||||
SENDOUQ_INFO_PAGE,
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
SENDOUQ_LOOKING_PREVIEW_PAGE,
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_PREPARING_PAGE,
|
||||
SENDOUQ_RULES_PAGE,
|
||||
SENDOUQ_SETTINGS_PAGE,
|
||||
SENDOUQ_STREAMS_PAGE,
|
||||
|
|
@ -55,19 +38,14 @@ import {
|
|||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SendouPopover } from "../../../components/elements/Popover";
|
||||
import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
|
||||
import { frontPageSchema } from "../q-schemas.server";
|
||||
import {
|
||||
groupRedirectLocationByCurrentLocation,
|
||||
userCanJoinQueueAt,
|
||||
} from "../q-utils";
|
||||
import { addMember } from "../queries/addMember.server";
|
||||
import { deleteLikesByGroupId } from "../queries/deleteLikesByGroupId.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { userCanJoinQueueAt } from "../q-utils";
|
||||
|
||||
import { action } from "../actions/q.server";
|
||||
import { loader } from "../loaders/q.server";
|
||||
export { loader, action };
|
||||
|
||||
import "../q.css";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
|
|
@ -87,132 +65,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
const validateCanJoinQ = async (user: { id: number; discordId: string }) => {
|
||||
const friendCode = await UserRepository.currentFriendCodeByUserId(user.id);
|
||||
errorToastIfFalsy(friendCode, "No friend code");
|
||||
const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW";
|
||||
|
||||
errorToastIfFalsy(currentSeason(new Date()), "Season is not active");
|
||||
errorToastIfFalsy(!findCurrentGroupByUserId(user.id), "Already in a group");
|
||||
errorToastIfFalsy(canJoinQueue, "Can't join queue right now");
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: frontPageSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "JOIN_QUEUE": {
|
||||
await validateCanJoinQ(user);
|
||||
|
||||
await QRepository.createGroup({
|
||||
status: data.direct === "true" ? "ACTIVE" : "PREPARING",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return redirect(
|
||||
data.direct === "true" ? SENDOUQ_LOOKING_PAGE : SENDOUQ_PREPARING_PAGE,
|
||||
);
|
||||
}
|
||||
case "JOIN_TEAM_WITH_TRUST":
|
||||
case "JOIN_TEAM": {
|
||||
await validateCanJoinQ(user);
|
||||
|
||||
const code = new URL(request.url).searchParams.get(
|
||||
JOIN_CODE_SEARCH_PARAM_KEY,
|
||||
);
|
||||
|
||||
const groupInvitedTo =
|
||||
code && user ? findGroupByInviteCode(code) : undefined;
|
||||
errorToastIfFalsy(
|
||||
groupInvitedTo,
|
||||
"Invite code doesn't match any active team",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
groupInvitedTo.members.length < FULL_GROUP_SIZE,
|
||||
"Team is full",
|
||||
);
|
||||
|
||||
sql.transaction(() => {
|
||||
addMember({
|
||||
groupId: groupInvitedTo.id,
|
||||
userId: user.id,
|
||||
role: "MANAGER",
|
||||
});
|
||||
deleteLikesByGroupId(groupInvitedTo.id);
|
||||
|
||||
if (data._action === "JOIN_TEAM_WITH_TRUST") {
|
||||
const owner = groupInvitedTo.members.find((m) => m.role === "OWNER");
|
||||
invariant(owner, "Owner not found");
|
||||
|
||||
giveTrust({
|
||||
trustGiverUserId: user.id,
|
||||
trustReceiverUserId: owner.id,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return redirect(
|
||||
groupInvitedTo.status === "PREPARING"
|
||||
? SENDOUQ_PREPARING_PAGE
|
||||
: SENDOUQ_LOOKING_PAGE,
|
||||
);
|
||||
}
|
||||
case "ADD_FRIEND_CODE": {
|
||||
errorToastIfFalsy(
|
||||
!(await UserRepository.currentFriendCodeByUserId(user.id)),
|
||||
"Friend code already set",
|
||||
);
|
||||
|
||||
await UserRepository.insertFriendCode({
|
||||
userId: user.id,
|
||||
friendCode: data.friendCode,
|
||||
submitterUserId: user.id,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await getUserId(request);
|
||||
|
||||
const code = new URL(request.url).searchParams.get(
|
||||
JOIN_CODE_SEARCH_PARAM_KEY,
|
||||
);
|
||||
|
||||
const redirectLocation = groupRedirectLocationByCurrentLocation({
|
||||
group: user ? findCurrentGroupByUserId(user.id) : undefined,
|
||||
currentLocation: "default",
|
||||
});
|
||||
|
||||
if (redirectLocation) {
|
||||
throw redirect(`${redirectLocation}${code ? "?joining=true" : ""}`);
|
||||
}
|
||||
|
||||
const groupInvitedTo = code && user ? findGroupByInviteCode(code) : undefined;
|
||||
|
||||
const now = new Date();
|
||||
const season = currentSeason(now);
|
||||
const upcomingSeason = !season ? nextSeason(now) : undefined;
|
||||
|
||||
return {
|
||||
season,
|
||||
upcomingSeason,
|
||||
groupInvitedTo,
|
||||
friendCode: user
|
||||
? await UserRepository.currentFriendCodeByUserId(user.id)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export default function QPage() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(true);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ import {
|
|||
USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
|
||||
} from "~/features/mmr/mmr-constants";
|
||||
import { ordinalToSp } from "~/features/mmr/mmr-utils";
|
||||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
|
||||
import { loader } from "../loaders/tiers.server";
|
||||
export { loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "SendouQ - Tiers",
|
||||
|
|
@ -27,15 +28,6 @@ export const handle: SendouRouteHandle = {
|
|||
i18n: ["q"],
|
||||
};
|
||||
|
||||
export const loader = () => {
|
||||
const season = currentOrPreviousSeason(new Date());
|
||||
const { intervals } = userSkills(season!.nth);
|
||||
|
||||
return {
|
||||
intervals,
|
||||
};
|
||||
};
|
||||
|
||||
export default function TiersPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
|
|||
import { SETTINGS_PAGE, navIconUrl } from "~/utils/urls";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SendouPopover } from "../../../components/elements/Popover";
|
||||
|
||||
import { action } from "../actions/settings.server";
|
||||
export { action };
|
||||
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ import {
|
|||
teamPage,
|
||||
uploadImagePage,
|
||||
} from "~/utils/urls";
|
||||
import { action } from "../actions/t.$customUrl.edit.server";
|
||||
import { loader } from "../loaders/t.$customUrl.edit.server";
|
||||
import { TEAM } from "../team-constants";
|
||||
import { canAddCustomizedColors, isTeamOwner } from "../team-utils";
|
||||
import "../team.css";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
import { action } from "../actions/t.$customUrl.edit.server";
|
||||
import { loader } from "../loaders/t.$customUrl.edit.server";
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { useTranslation } from "react-i18next";
|
|||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { action } from "../actions/t.$customUrl.join.server";
|
||||
import { loader } from "../loaders/t.$customUrl.join.server";
|
||||
import "../team.css";
|
||||
|
||||
import { action } from "../actions/t.$customUrl.join.server";
|
||||
import { loader } from "../loaders/t.$customUrl.join.server";
|
||||
export { loader, action };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ import { TEAM_MEMBER_ROLES } from "../team-constants";
|
|||
import { isTeamFull } from "../team-utils";
|
||||
import "../team.css";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
import { action } from "../actions/t.$customUrl.roster.server";
|
||||
import { loader } from "../loaders/t.$customUrl.roster.server";
|
||||
|
||||
export { loader, action };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ import {
|
|||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import type * as TeamRepository from "../TeamRepository.server";
|
||||
import { action } from "../actions/t.$customUrl.server";
|
||||
import { loader } from "../loaders/t.$customUrl.server";
|
||||
import {
|
||||
isTeamManager,
|
||||
isTeamMember,
|
||||
|
|
@ -39,6 +37,9 @@ import {
|
|||
} from "../team-utils";
|
||||
import "../team.css";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
||||
import { action } from "../actions/t.$customUrl.server";
|
||||
import { loader } from "../loaders/t.$customUrl.server";
|
||||
export { action, loader };
|
||||
|
||||
export const meta: MetaFunction<typeof loader> = (args) => {
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@ import {
|
|||
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
|
||||
import { TEAM, TEAMS_PER_PAGE } from "../team-constants";
|
||||
|
||||
import "../team.css";
|
||||
|
||||
import { action } from "../actions/t.server";
|
||||
import { loader } from "../loaders/t.server";
|
||||
export { loader, action };
|
||||
|
||||
import "../team.css";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Team Search",
|
||||
|
|
|
|||
25
app/features/top-search/loaders/xsearch.player.$id.server.ts
Normal file
25
app/features/top-search/loaders/xsearch.player.$id.server.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { removeDuplicates } from "~/utils/arrays";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import { findPlacementsByPlayerId } from "../queries/findPlacements.server";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const placements = notFoundIfFalsy(
|
||||
findPlacementsByPlayerId(Number(params.id)),
|
||||
);
|
||||
|
||||
const primaryName = placements[0].name;
|
||||
const aliases = removeDuplicates(
|
||||
placements
|
||||
.map((placement) => placement.name)
|
||||
.filter((name) => name !== primaryName),
|
||||
);
|
||||
|
||||
return {
|
||||
placements,
|
||||
names: {
|
||||
primary: primaryName,
|
||||
aliases,
|
||||
},
|
||||
};
|
||||
};
|
||||
62
app/features/top-search/loaders/xsearch.server.ts
Normal file
62
app/features/top-search/loaders/xsearch.server.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import type { RankedModeShort } from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { findPlacementsOfMonth } from "../queries/findPlacements.server";
|
||||
import { monthYears } from "../queries/monthYears";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const availableMonthYears = monthYears();
|
||||
const { month: latestMonth, year: latestYear } = availableMonthYears[0];
|
||||
|
||||
const url = new URL(request.url);
|
||||
const mode = (() => {
|
||||
const mode = url.searchParams.get("mode");
|
||||
if (rankedModesShort.includes(mode as any)) {
|
||||
return mode as RankedModeShort;
|
||||
}
|
||||
|
||||
return "SZ";
|
||||
})();
|
||||
const region = (() => {
|
||||
const region = url.searchParams.get("region");
|
||||
if (region === "WEST" || region === "JPN") {
|
||||
return region;
|
||||
}
|
||||
|
||||
return "WEST";
|
||||
})();
|
||||
const month = (() => {
|
||||
const month = url.searchParams.get("month");
|
||||
if (month) {
|
||||
const monthNumber = Number(month);
|
||||
if (monthNumber >= 1 && monthNumber <= 12) {
|
||||
return monthNumber;
|
||||
}
|
||||
}
|
||||
|
||||
return latestMonth;
|
||||
})();
|
||||
const year = (() => {
|
||||
const year = url.searchParams.get("year");
|
||||
if (year) {
|
||||
const yearNumber = Number(year);
|
||||
if (yearNumber >= 2023) {
|
||||
return yearNumber;
|
||||
}
|
||||
}
|
||||
|
||||
return latestYear;
|
||||
})();
|
||||
|
||||
const placements = findPlacementsOfMonth({
|
||||
mode,
|
||||
region,
|
||||
month,
|
||||
year,
|
||||
});
|
||||
|
||||
return {
|
||||
placements,
|
||||
availableMonthYears,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,14 +1,9 @@
|
|||
import type {
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Main } from "~/components/Main";
|
||||
import { removeDuplicates } from "~/utils/arrays";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import { type SendouRouteHandle, notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
navIconUrl,
|
||||
topSearchPage,
|
||||
|
|
@ -16,7 +11,9 @@ import {
|
|||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { PlacementsTable } from "../components/Placements";
|
||||
import { findPlacementsByPlayerId } from "../queries/findPlacements.server";
|
||||
|
||||
import { loader } from "../loaders/xsearch.player.$id.server";
|
||||
export { loader };
|
||||
|
||||
import "../top-search.css";
|
||||
|
||||
|
|
@ -58,27 +55,6 @@ export const meta: MetaFunction<typeof loader> = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const placements = notFoundIfFalsy(
|
||||
findPlacementsByPlayerId(Number(params.id)),
|
||||
);
|
||||
|
||||
const primaryName = placements[0].name;
|
||||
const aliases = removeDuplicates(
|
||||
placements
|
||||
.map((placement) => placement.name)
|
||||
.filter((name) => name !== primaryName),
|
||||
);
|
||||
|
||||
return {
|
||||
placements,
|
||||
names: {
|
||||
primary: primaryName,
|
||||
aliases,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function XSearchPlayerPage() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user