mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-09 04:02:40 -05:00
Merge branch 'sendou-ink:main' into main
This commit is contained in:
commit
ecd5ececb2
|
|
@ -82,10 +82,10 @@ pnpm exec playwright show-trace test-results/<test-folder>/trace.zip
|
|||
|
||||
## Test pattern reference
|
||||
|
||||
Every test follows this pattern — use these imports from `~/utils/playwright`, NOT raw Playwright APIs:
|
||||
Every test follows this pattern — use these imports from `./helpers/playwright`, NOT raw Playwright APIs:
|
||||
|
||||
```typescript
|
||||
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
|
||||
import { expect, impersonate, navigate, seed, test } from "./helpers/playwright";
|
||||
|
||||
test.describe("Feature", () => {
|
||||
test("does something", async ({ page }) => {
|
||||
|
|
@ -104,7 +104,7 @@ Key rules:
|
|||
- Use `seed(page, variation?)` to reset the database. Available variations: DEFAULT, NO_TOURNAMENT_TEAMS, REG_OPEN, SMALL_SOS, NZAP_IN_TEAM, NO_SCRIMS, NO_SQ_GROUPS
|
||||
- Use `impersonate(page, userId?)` to authenticate. Default is admin (ADMIN_ID)
|
||||
- Avoid `page.waitForTimeout` — use assertions or `waitFor` patterns instead
|
||||
- Import `test` from `~/utils/playwright` (not from `@playwright/test`) — it includes worker port fixtures
|
||||
- Import `test` from `./helpers/playwright` (not from `@playwright/test`) — it includes worker port fixtures
|
||||
|
||||
## Environment variables
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
- always use named exports
|
||||
- Remeda is the utility library of choice
|
||||
- date-fns should be used for date related logic
|
||||
- do not use `forEach`, prefer `for...of`
|
||||
|
||||
## React
|
||||
|
||||
|
|
@ -47,6 +48,7 @@
|
|||
- one file containing React code should have a matching CSS module file e.g. `Component.tsx` should have a file with the same root name i.e. `Component.module.css`
|
||||
- clsx library is used for conditional class names
|
||||
- prefer using [CSS variables](./app/styles/vars.css) for theming
|
||||
- for any CSS variable used, make sure it is defined either locally or in the `vars.css` file
|
||||
- for simple styling, prefer [utility classes](./app/styles/utils.css) over creating a new class
|
||||
- use CSS nesting with the `&` selector to group related selectors (pseudo-classes, pseudo-elements, child selectors, attribute selectors) under their parent instead of repeating the parent selector
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ Another key objective is to bridge the gap between casual and competitive player
|
|||
|
||||
- [Git](https://git-scm.com/)
|
||||
- [Node.js v22](https://nodejs.org/en)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
|
||||
Optionally [nvm](https://github.com/nvm-sh/nvm) can be convenient for managing multiple Node.js installs
|
||||
|
||||
|
|
@ -66,6 +67,7 @@ First verify you have Node.js and git installed:
|
|||
```bash
|
||||
node --version
|
||||
git --version
|
||||
pnpm --version
|
||||
```
|
||||
|
||||
You should see something like:
|
||||
|
|
@ -73,6 +75,7 @@ You should see something like:
|
|||
```
|
||||
v22.13.0
|
||||
git version 2.39.5 (Apple Git-154)
|
||||
10.33.0
|
||||
```
|
||||
(if not then go back to "Prerequisites" and install what is missing)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,5 +15,4 @@ i18next.use(initReactI18next).init({
|
|||
...config,
|
||||
lng: "en",
|
||||
resources,
|
||||
showSupportNotice: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
{isHydrated
|
||||
? formatDate(databaseTimestampToDate(updatedAt), {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
: "t"}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import clsx from "clsx";
|
|||
import * as React from "react";
|
||||
import { type AxisOptions, Chart as ReactChart } from "react-charts";
|
||||
import type { TooltipRendererProps } from "react-charts/types/components/TooltipRenderer";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Theme, useTheme } from "~/features/theme/core/provider";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
|
|
@ -23,9 +22,9 @@ export default function Chart({
|
|||
valueSuffix?: string;
|
||||
xAxis: "linear" | "localTime";
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isHydrated = useHydrated();
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const primaryAxis = React.useMemo<
|
||||
AxisOptions<(typeof options)[number]["data"][number]>
|
||||
|
|
@ -38,7 +37,7 @@ export default function Chart({
|
|||
formatters: {
|
||||
scale: (val: any) => {
|
||||
if (val instanceof Date) {
|
||||
return val.toLocaleDateString(i18n.language, {
|
||||
return formatDate(val, {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
});
|
||||
|
|
@ -48,7 +47,7 @@ export default function Chart({
|
|||
},
|
||||
},
|
||||
}),
|
||||
[i18n.language, xAxis],
|
||||
[formatDate, xAxis],
|
||||
);
|
||||
|
||||
const secondaryAxes = React.useMemo<
|
||||
|
|
@ -117,7 +116,7 @@ function ChartTooltip({
|
|||
return formatDate(primaryValue, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { isToday, isTomorrow } from "date-fns";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { SidebarEvent } from "~/features/sidebar/core/sidebar.server";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import styles from "./EventsList.module.css";
|
||||
import { Placeholder } from "./Placeholder";
|
||||
import { ListLink } from "./SideNav";
|
||||
|
|
@ -14,6 +15,7 @@ export function EventsList({
|
|||
onClick?: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["front"]);
|
||||
const { formatDate, formatTime } = useTimeFormat();
|
||||
const isHydrated = useHydrated();
|
||||
|
||||
if (events.length === 0) {
|
||||
|
|
@ -48,20 +50,13 @@ export function EventsList({
|
|||
const str = rtf.format(1, "day");
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
return date.toLocaleDateString(i18n.language, {
|
||||
return formatDate(date, {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const groupedEvents = events.reduce<Record<string, typeof events>>(
|
||||
(acc, event) => {
|
||||
const key = getDayKey(event.startTime);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
& > label {
|
||||
margin: 0;
|
||||
text-box: trim-start cap alphabetic;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function RelativeTime({
|
|||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
timeZoneName: "short",
|
||||
})
|
||||
: undefined
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next";
|
|||
import { useFetcher } from "react-router";
|
||||
import type { SidebarStream } from "~/features/core/streams/streams.server";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { navIconUrl, tournamentRegisterPage } from "~/utils/urls";
|
||||
import { Image } from "./Image";
|
||||
import { ListLink } from "./SideNav";
|
||||
|
|
@ -25,14 +25,12 @@ export function StreamListItems({
|
|||
savedTournamentIds?: number[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["front"]);
|
||||
const { formatDateTime, formatTime, formatDistanceToNow } = useTimeFormat();
|
||||
const isHydrated = useHydrated();
|
||||
|
||||
const formatRelativeDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const timeStr = date.toLocaleTimeString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const timeStr = formatTime(date);
|
||||
|
||||
if (isToday(date)) {
|
||||
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
|
||||
|
|
@ -49,8 +47,8 @@ export function StreamListItems({
|
|||
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(i18n.language, {
|
||||
month: "short",
|
||||
return formatDateTime(date, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
|
|
@ -101,7 +99,6 @@ export function StreamListItems({
|
|||
) : (
|
||||
formatDistanceToNow(startsAtDate, {
|
||||
addSuffix: true,
|
||||
language: i18n.language as LanguageCode,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function TimePopover({
|
|||
minute: "numeric",
|
||||
hour: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
},
|
||||
underline = true,
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface SendouButtonProps
|
|||
shape?: "circle" | "square";
|
||||
icon?: JSX.Element;
|
||||
children?: React.ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function SendouButton({
|
||||
|
|
@ -37,10 +38,12 @@ export function SendouButton({
|
|||
shape,
|
||||
className,
|
||||
icon,
|
||||
testId,
|
||||
...rest
|
||||
}: SendouButtonProps) {
|
||||
return (
|
||||
<ReactAriaButton
|
||||
data-testid={testId}
|
||||
{...rest}
|
||||
className={buttonClassName({ className, variant, size, shape })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -54,14 +54,18 @@ export interface SendouMenuItemProps extends MenuItemProps {
|
|||
export function SendouMenuSection({
|
||||
children,
|
||||
headerText,
|
||||
headerClassName,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
headerText?: string;
|
||||
headerText?: React.ReactNode;
|
||||
headerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Section>
|
||||
{headerText ? (
|
||||
<Header className={styles.menuHeader}>{headerText}</Header>
|
||||
<Header className={clsx(styles.menuHeader, headerClassName)}>
|
||||
{headerText}
|
||||
</Header>
|
||||
) : null}
|
||||
{children}
|
||||
</Section>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { format, sub } from "date-fns";
|
||||
import { sub } from "date-fns";
|
||||
import { ChevronsUpDown, Search, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
|
|
@ -21,6 +21,7 @@ import { useDebounce } from "react-use";
|
|||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { SendouLabel } from "~/components/elements/Label";
|
||||
import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
||||
import selectStyles from "./Select.module.css";
|
||||
|
|
@ -150,6 +151,7 @@ function TournamentItem({
|
|||
};
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
if (typeof item.id === "string") {
|
||||
return (
|
||||
|
|
@ -167,7 +169,11 @@ function TournamentItem({
|
|||
|
||||
const additionalText = () => {
|
||||
const date = databaseTimestampToDate(item.startTime);
|
||||
return format(date, "MMM d, yyyy");
|
||||
return formatDate(date, {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -185,11 +191,9 @@ function TournamentItem({
|
|||
<img src={item.logoUrl} alt="" className={tournamentSearchStyles.logo} />
|
||||
<div className={tournamentSearchStyles.itemTextsContainer}>
|
||||
<span>{item.name}</span>
|
||||
{additionalText() ? (
|
||||
<div className={tournamentSearchStyles.itemAdditionalText}>
|
||||
{additionalText()}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={tournamentSearchStyles.itemAdditionalText}>
|
||||
{additionalText()}
|
||||
</div>
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import clsx from "clsx";
|
|||
import { ArrowLeft, MessageSquare, X } from "lucide-react";
|
||||
import { Button } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useFetcher } from "react-router";
|
||||
import { useCurrentRouteChatCode } from "~/features/chat/ChatProvider";
|
||||
import {
|
||||
extractRoomLink,
|
||||
isMatchRoomUrl,
|
||||
} from "~/features/chat/chat-constants";
|
||||
import { resolveDatePlaceholders } from "~/features/chat/chat-utils";
|
||||
import { Chat } from "~/features/chat/components/Chat";
|
||||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
|
|
@ -62,8 +67,18 @@ function RoomList({ onClose }: { onClose?: () => void }) {
|
|||
const chatContext = useChatContext()!;
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
const nonExpiredRooms = chatContext.rooms
|
||||
.filter((room) => room.expiresAt > Date.now())
|
||||
const rawRouteChatCode = useCurrentRouteChatCode();
|
||||
const routeChatCodes = rawRouteChatCode
|
||||
? Array.isArray(rawRouteChatCode)
|
||||
? rawRouteChatCode
|
||||
: [rawRouteChatCode]
|
||||
: [];
|
||||
|
||||
const visibleRooms = chatContext.rooms
|
||||
.filter(
|
||||
(room) =>
|
||||
room.expiresAt > Date.now() || routeChatCodes.includes(room.chatCode),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.isObsolete !== b.isObsolete) return a.isObsolete ? 1 : -1;
|
||||
const aRecency = a.lastMessageTimestamp || a.createdAt;
|
||||
|
|
@ -75,12 +90,12 @@ function RoomList({ onClose }: { onClose?: () => void }) {
|
|||
<div className={styles.sidebar}>
|
||||
<SidebarHeader onClose={onClose} />
|
||||
<div className={styles.roomList}>
|
||||
{nonExpiredRooms.length === 0 ? (
|
||||
{visibleRooms.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
{t("common:chat.sidebar.noActiveChats")}
|
||||
</div>
|
||||
) : (
|
||||
nonExpiredRooms.map((room) => {
|
||||
visibleRooms.map((room) => {
|
||||
const unread = chatContext.unreadCounts[room.chatCode] ?? 0;
|
||||
|
||||
return (
|
||||
|
|
@ -114,7 +129,7 @@ function RoomList({ onClose }: { onClose?: () => void }) {
|
|||
>
|
||||
{resolveDatePlaceholders(room.header, (d) =>
|
||||
formatDateTime(d, {
|
||||
month: "short",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
|
|
@ -154,6 +169,7 @@ function ChatView({ onClose }: { onClose?: () => void }) {
|
|||
.filter(([code]) => code !== activeRoom)
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
|
||||
const roomLinkFetcher = useFetcher();
|
||||
const room = chatContext.rooms.find((r) => r.chatCode === activeRoom);
|
||||
const roomExpired = Boolean(room?.expiresAt && room.expiresAt < Date.now());
|
||||
const messages = chatContext.messagesForRoom(activeRoom);
|
||||
|
|
@ -169,9 +185,27 @@ function ChatView({ onClose }: { onClose?: () => void }) {
|
|||
}
|
||||
}
|
||||
|
||||
const isMatchRoom = room?.url ? isMatchRoomUrl(room.url) : false;
|
||||
|
||||
const chatAdapter = {
|
||||
messages,
|
||||
send: (contents: string) => chatContext.send(activeRoom, contents),
|
||||
send: (contents: string) => {
|
||||
chatContext.send(activeRoom, contents);
|
||||
|
||||
if (isMatchRoom) {
|
||||
const link = extractRoomLink(contents);
|
||||
if (link) {
|
||||
roomLinkFetcher.submit(
|
||||
{ _action: "UPSERT", url: link },
|
||||
{
|
||||
method: "post",
|
||||
action: "/room",
|
||||
encType: "application/json",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
currentRoom: activeRoom,
|
||||
setCurrentRoom: () => {},
|
||||
readyState: chatContext.readyState,
|
||||
|
|
@ -202,7 +236,7 @@ function ChatView({ onClose }: { onClose?: () => void }) {
|
|||
room?.header ?? t("common:chat.sidebar.title"),
|
||||
(d) =>
|
||||
formatDateTime(d, {
|
||||
month: "short",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
|
|
|
|||
|
|
@ -231,7 +231,10 @@ function GlobalSearchContent({
|
|||
);
|
||||
|
||||
const hasQuery = query.length >= 3;
|
||||
const fetchedQuery = fetcher.data?.query ?? null;
|
||||
const fetchedType = fetcher.data?.type ?? null;
|
||||
const isCurrentFetch =
|
||||
hasQuery && fetchedQuery === query && fetchedType === searchType;
|
||||
const results =
|
||||
hasQuery && fetchedType === searchType ? (fetcher.data?.results ?? []) : [];
|
||||
|
||||
|
|
@ -388,15 +391,27 @@ function GlobalSearchContent({
|
|||
className={clsx(styles.listBox, "scrollbar")}
|
||||
aria-label={t("common:search")}
|
||||
onAction={handleSelect}
|
||||
renderEmptyState={() =>
|
||||
hasQuery ? (
|
||||
renderEmptyState={() => {
|
||||
if (!hasQuery) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
{t("common:search.hint")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isCurrentFetch) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
{t("common:search.searching")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
{t("common:search.noResults")}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.emptyState}>{t("common:search.hint")}</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{results.map((result) => (
|
||||
<ListBoxItem
|
||||
|
|
|
|||
|
|
@ -73,9 +73,10 @@ function CategoryMenu({
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
const user = useUser();
|
||||
const isStaff = user?.roles.includes("STAFF") ?? false;
|
||||
const showStaffOnly = isStaff || process.env.NODE_ENV === "development";
|
||||
|
||||
const visibleItems = category.items.filter(
|
||||
(item) => !("staffOnly" in item) || isStaff,
|
||||
(item) => !("staffOnly" in item) || showStaffOnly,
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
import { FriendMenu } from "~/features/friends/components/FriendMenu";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import type { RootLoaderData } from "~/root";
|
||||
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
|
|
@ -55,12 +56,9 @@ import { TopRightButtons } from "./TopRightButtons";
|
|||
|
||||
const MAX_DESKTOP_FRIENDS = 4;
|
||||
|
||||
function useTimeFormat() {
|
||||
function useRelativeDayFormat() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const formatTime = (date: Date, options: Intl.DateTimeFormatOptions) => {
|
||||
return date.toLocaleTimeString(i18n.language, options);
|
||||
};
|
||||
const { formatTime, formatDateTime } = useTimeFormat();
|
||||
|
||||
const formatRelativeDay = (daysFromToday: number) => {
|
||||
const rtf = new Intl.RelativeTimeFormat(i18n.language, { numeric: "auto" });
|
||||
|
|
@ -70,7 +68,7 @@ function useTimeFormat() {
|
|||
|
||||
const formatRelativeDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const timeStr = formatTime(date, { hour: "numeric", minute: "2-digit" });
|
||||
const timeStr = formatTime(date);
|
||||
|
||||
if (isToday(date)) {
|
||||
return `${formatRelativeDay(0)}, ${timeStr}`;
|
||||
|
|
@ -79,15 +77,15 @@ function useTimeFormat() {
|
|||
return `${formatRelativeDay(1)}, ${timeStr}`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString(i18n.language, {
|
||||
month: "short",
|
||||
return formatDateTime(date, {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
return { formatTime, formatRelativeDate };
|
||||
return { formatRelativeDate };
|
||||
}
|
||||
|
||||
function useBreadcrumbData() {
|
||||
|
|
@ -215,7 +213,7 @@ export function Layout({
|
|||
const setChatSidebarOpen = chatContext?.setChatOpen ?? (() => {});
|
||||
|
||||
const { t } = useTranslation(["front", "common"]);
|
||||
const { formatRelativeDate } = useTimeFormat();
|
||||
const { formatRelativeDate } = useRelativeDayFormat();
|
||||
const isHydrated = useHydrated();
|
||||
const location = useLocation();
|
||||
const headerRef = React.useRef<HTMLElement>(null);
|
||||
|
|
|
|||
197
app/components/match-page/MatchActionPickBanTab.module.css
Normal file
197
app/components/match-page/MatchActionPickBanTab.module.css
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"options"
|
||||
"prompt"
|
||||
"submit";
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
gap: var(--s-6);
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: header;
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-semi);
|
||||
text-align: center;
|
||||
text-box: trim-start cap alphabetic;
|
||||
}
|
||||
|
||||
.options {
|
||||
grid-area: options;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-6);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
grid-area: prompt;
|
||||
margin: 0;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-lighter);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.verbPick {
|
||||
color: var(--color-success);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.verbBan {
|
||||
color: var(--color-error);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.submit {
|
||||
grid-area: submit;
|
||||
}
|
||||
|
||||
.waiting {
|
||||
grid-row: prompt-start / submit-end;
|
||||
margin: 0;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-lighter);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modeGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.divider {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
border-bottom: 2px dotted var(--color-bg-higher);
|
||||
}
|
||||
}
|
||||
|
||||
.stageGrid {
|
||||
--tile-width: 90px;
|
||||
--tile-height: 50px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--s-4) var(--s-3);
|
||||
}
|
||||
|
||||
.modeGrid {
|
||||
--tile-width: 90px;
|
||||
--tile-height: 90px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--s-4) var(--s-3);
|
||||
}
|
||||
|
||||
.tileContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: var(--tile-width);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tileWrapper {
|
||||
position: relative;
|
||||
width: var(--tile-width);
|
||||
height: var(--tile-height);
|
||||
}
|
||||
|
||||
.tile {
|
||||
height: var(--tile-height);
|
||||
width: var(--tile-width);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
transition:
|
||||
filter,
|
||||
opacity 0.2s;
|
||||
border-radius: var(--radius-box);
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.stageTile {
|
||||
background-image: var(--map-image-url);
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.modeTile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.tileSelected {
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.tileIcon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tileIconPick {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.tileIconBan {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.tileNumber {
|
||||
position: absolute;
|
||||
background-color: var(--color-text-accent);
|
||||
border-radius: 100%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
top: -5px;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tileFrom {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
margin-block-start: var(--s-0-5);
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
font-weight: var(--weight-semi);
|
||||
margin-block-start: var(--s-1);
|
||||
}
|
||||
380
app/components/match-page/MatchActionPickBanTab.tsx
Normal file
380
app/components/match-page/MatchActionPickBanTab.tsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import clsx from "clsx";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { stageImageUrl } from "~/utils/urls";
|
||||
import { Divider } from "../Divider";
|
||||
import { SendouTabPanel } from "../elements/Tabs";
|
||||
import { ModeImage } from "../Image";
|
||||
import styles from "./MatchActionPickBanTab.module.css";
|
||||
import { TAB_KEYS } from "./MatchTabs";
|
||||
import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter";
|
||||
|
||||
export interface PickBanMapOption {
|
||||
stageId?: StageId;
|
||||
mode?: ModeShort;
|
||||
picker?: "US" | "THEM" | "BOTH";
|
||||
nth?: number;
|
||||
}
|
||||
|
||||
interface PickBanSubmission {
|
||||
type: "PICK" | "BAN";
|
||||
map: PickBanMapOption;
|
||||
}
|
||||
|
||||
interface MatchActionPickBanTabProps {
|
||||
options: PickBanMapOption[];
|
||||
type: "PICK" | "BAN";
|
||||
onSubmit?: (data: PickBanSubmission) => void;
|
||||
isSubmitting?: boolean;
|
||||
weaponReport?: WeaponReporterProps;
|
||||
waitingFor?: string;
|
||||
}
|
||||
|
||||
export function MatchActionPickBanTab({
|
||||
options,
|
||||
type,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
weaponReport,
|
||||
waitingFor,
|
||||
}: MatchActionPickBanTabProps) {
|
||||
const { t } = useTranslation(["q", "common", "game-misc"]);
|
||||
const [selected, setSelected] = useState<PickBanMapOption>();
|
||||
const isWaiting = waitingFor !== undefined;
|
||||
|
||||
const hasStage = options.every((option) => option.stageId !== undefined);
|
||||
const hasMode = options.every((option) => option.mode !== undefined);
|
||||
const layout: "STAGE_BY_MODE" | "STAGE_ONLY" | "MODE_ONLY" =
|
||||
hasStage && hasMode
|
||||
? "STAGE_BY_MODE"
|
||||
: hasStage
|
||||
? "STAGE_ONLY"
|
||||
: "MODE_ONLY";
|
||||
|
||||
const titleKey =
|
||||
layout === "MODE_ONLY"
|
||||
? type === "PICK"
|
||||
? "q:match.action.pickMode"
|
||||
: "q:match.action.banMode"
|
||||
: type === "PICK"
|
||||
? "q:match.action.pickStage"
|
||||
: "q:match.action.banStage";
|
||||
|
||||
const selectedLabel = (() => {
|
||||
if (!selected) return null;
|
||||
const stageName =
|
||||
selected.stageId !== undefined
|
||||
? t(`game-misc:STAGE_${selected.stageId}`)
|
||||
: null;
|
||||
const modeName =
|
||||
selected.mode !== undefined
|
||||
? t(
|
||||
selected.stageId !== undefined
|
||||
? `game-misc:MODE_SHORT_${selected.mode}`
|
||||
: `game-misc:MODE_LONG_${selected.mode}`,
|
||||
)
|
||||
: null;
|
||||
if (stageName && modeName) return `${stageName} (${modeName})`;
|
||||
return stageName ?? modeName;
|
||||
})();
|
||||
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.ACTION}>
|
||||
<div className={styles.root}>
|
||||
<div className={styles.title}>{t(titleKey)}</div>
|
||||
|
||||
<div className={styles.options}>
|
||||
{layout === "STAGE_BY_MODE" ? (
|
||||
<StageByModeGrid
|
||||
options={options}
|
||||
type={type}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
disabled={isWaiting}
|
||||
/>
|
||||
) : layout === "STAGE_ONLY" ? (
|
||||
<StageOnlyGrid
|
||||
options={options}
|
||||
type={type}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
disabled={isWaiting}
|
||||
/>
|
||||
) : (
|
||||
<ModeOnlyGrid
|
||||
options={options}
|
||||
type={type}
|
||||
selected={selected}
|
||||
onSelect={setSelected}
|
||||
disabled={isWaiting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isWaiting ? (
|
||||
<p className={styles.waiting}>
|
||||
{t("q:match.action.pickBanWaiting", { teamName: waitingFor })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.prompt}>
|
||||
{selectedLabel ? (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
type === "PICK" ? styles.verbPick : styles.verbBan
|
||||
}
|
||||
>
|
||||
{type === "PICK"
|
||||
? t("q:match.action.picking")
|
||||
: t("q:match.action.banning")}
|
||||
</span>{" "}
|
||||
{selectedLabel}
|
||||
</>
|
||||
) : (
|
||||
t("q:match.action.pickBanPrompt")
|
||||
)}
|
||||
</p>
|
||||
|
||||
<SendouButton
|
||||
variant="primary"
|
||||
className={styles.submit}
|
||||
isDisabled={!selected || isSubmitting}
|
||||
onPress={() => {
|
||||
if (!selected) return;
|
||||
onSubmit?.({ type, map: selected });
|
||||
}}
|
||||
testId="pick-ban-submit-button"
|
||||
>
|
||||
{t("common:actions.submit")}
|
||||
</SendouButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function StageByModeGrid({
|
||||
options,
|
||||
type,
|
||||
selected,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
options: PickBanMapOption[];
|
||||
type: "PICK" | "BAN";
|
||||
selected?: PickBanMapOption;
|
||||
onSelect: (option: PickBanMapOption) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const modesInOrder: ModeShort[] = [];
|
||||
const byMode = new Map<ModeShort, PickBanMapOption[]>();
|
||||
for (const option of options) {
|
||||
const mode = option.mode!;
|
||||
if (!byMode.has(mode)) {
|
||||
byMode.set(mode, []);
|
||||
modesInOrder.push(mode);
|
||||
}
|
||||
byMode.get(mode)!.push(option);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{modesInOrder.map((mode) => (
|
||||
<div key={mode} className={styles.modeGroup}>
|
||||
<Divider className={styles.divider}>
|
||||
<ModeImage mode={mode} size={32} />
|
||||
</Divider>
|
||||
<div className={styles.stageGrid}>
|
||||
{byMode.get(mode)!.map((option) => (
|
||||
<StageTile
|
||||
key={`${option.stageId}-${option.mode}`}
|
||||
option={option}
|
||||
type={type}
|
||||
isSelected={isSameOption(option, selected)}
|
||||
onSelect={() => onSelect(option)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StageOnlyGrid({
|
||||
options,
|
||||
type,
|
||||
selected,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
options: PickBanMapOption[];
|
||||
type: "PICK" | "BAN";
|
||||
selected?: PickBanMapOption;
|
||||
onSelect: (option: PickBanMapOption) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.stageGrid}>
|
||||
{options.map((option) => (
|
||||
<StageTile
|
||||
key={option.stageId}
|
||||
option={option}
|
||||
type={type}
|
||||
isSelected={isSameOption(option, selected)}
|
||||
onSelect={() => onSelect(option)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeOnlyGrid({
|
||||
options,
|
||||
type,
|
||||
selected,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
options: PickBanMapOption[];
|
||||
type: "PICK" | "BAN";
|
||||
selected?: PickBanMapOption;
|
||||
onSelect: (option: PickBanMapOption) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.modeGrid}>
|
||||
{options.map((option) => (
|
||||
<ModeTile
|
||||
key={option.mode}
|
||||
option={option}
|
||||
type={type}
|
||||
isSelected={isSameOption(option, selected)}
|
||||
onSelect={() => onSelect(option)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageTile({
|
||||
option,
|
||||
type,
|
||||
isSelected,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
option: PickBanMapOption;
|
||||
type: "PICK" | "BAN";
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["q", "game-misc"]);
|
||||
|
||||
return (
|
||||
<div className={styles.tileContainer}>
|
||||
<div className={styles.tileWrapper}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.tile, styles.stageTile, {
|
||||
[styles.tileSelected]: isSelected,
|
||||
})}
|
||||
style={{
|
||||
"--map-image-url": `url("${stageImageUrl(option.stageId!)}.avif")`,
|
||||
}}
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
data-testid="pick-ban-button"
|
||||
/>
|
||||
{isSelected ? (
|
||||
type === "PICK" ? (
|
||||
<Check className={clsx(styles.tileIcon, styles.tileIconPick)} />
|
||||
) : (
|
||||
<X className={clsx(styles.tileIcon, styles.tileIconBan)} />
|
||||
)
|
||||
) : null}
|
||||
{option.nth ? (
|
||||
<span className={styles.tileNumber}>{option.nth}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.tileLabel}>
|
||||
{shortStageName(t(`game-misc:STAGE_${option.stageId!}`))}
|
||||
</div>
|
||||
{option.picker ? (
|
||||
<span
|
||||
className={clsx(styles.tileFrom, {
|
||||
"text-theme": option.picker === "BOTH",
|
||||
"text-success": option.picker === "US",
|
||||
"text-error": option.picker === "THEM",
|
||||
})}
|
||||
>
|
||||
{option.picker === "US"
|
||||
? t("q:match.action.pickerUs")
|
||||
: option.picker === "THEM"
|
||||
? t("q:match.action.pickerThem")
|
||||
: t("q:match.action.pickerBoth")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeTile({
|
||||
option,
|
||||
type,
|
||||
isSelected,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
option: PickBanMapOption;
|
||||
type: "PICK" | "BAN";
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
|
||||
return (
|
||||
<div className={styles.tileContainer}>
|
||||
<div className={styles.tileWrapper}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.tile, styles.modeTile, {
|
||||
[styles.tileSelected]: isSelected,
|
||||
})}
|
||||
onClick={onSelect}
|
||||
disabled={disabled}
|
||||
data-testid="pick-ban-button"
|
||||
>
|
||||
<ModeImage mode={option.mode!} size={48} />
|
||||
</button>
|
||||
{isSelected ? (
|
||||
type === "PICK" ? (
|
||||
<Check className={clsx(styles.tileIcon, styles.tileIconPick)} />
|
||||
) : (
|
||||
<X className={clsx(styles.tileIcon, styles.tileIconBan)} />
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.tileLabel}>
|
||||
{t(`game-misc:MODE_LONG_${option.mode!}`)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isSameOption(a: PickBanMapOption, b: PickBanMapOption | undefined) {
|
||||
if (!b) return false;
|
||||
return a.stageId === b.stageId && a.mode === b.mode;
|
||||
}
|
||||
273
app/components/match-page/MatchActionTab.module.css
Normal file
273
app/components/match-page/MatchActionTab.module.css
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
"actions actions actions"
|
||||
"selection selection selection"
|
||||
"submit submit submit";
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
gap: var(--s-5);
|
||||
container-type: inline-size;
|
||||
|
||||
@container (max-width: 599px) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"actions"
|
||||
"selection"
|
||||
"submit";
|
||||
}
|
||||
}
|
||||
|
||||
.withPoints {
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
"actions actions actions"
|
||||
"selection selection selection"
|
||||
"ko ko ko"
|
||||
"submit submit submit";
|
||||
|
||||
@container (max-width: 599px) {
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"actions"
|
||||
"selection"
|
||||
"ko"
|
||||
"submit";
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: header;
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-semi);
|
||||
text-align: center;
|
||||
text-box: trim-start cap alphabetic;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
margin-block-start: calc(-1 * var(--s-4));
|
||||
}
|
||||
|
||||
.selectionRow {
|
||||
grid-area: selection;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas:
|
||||
"alpha stage bravo"
|
||||
". text .";
|
||||
column-gap: var(--s-4);
|
||||
row-gap: var(--s-1);
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
@container (max-width: 599px) {
|
||||
column-gap: var(--s-2);
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas:
|
||||
"stage alpha"
|
||||
"stage bravo"
|
||||
"text .";
|
||||
}
|
||||
}
|
||||
|
||||
.teamRadioContainer {
|
||||
--label-margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 250px;
|
||||
|
||||
@container (max-width: 599px) {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.alpha {
|
||||
grid-area: alpha;
|
||||
justify-self: end;
|
||||
|
||||
@container (max-width: 599px) {
|
||||
justify-self: stretch;
|
||||
align-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
.bravo {
|
||||
grid-area: bravo;
|
||||
justify-self: start;
|
||||
|
||||
@container (max-width: 599px) {
|
||||
justify-self: stretch;
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.stageImageWrapper {
|
||||
grid-area: stage;
|
||||
|
||||
@container (max-width: 599px) {
|
||||
align-self: stretch;
|
||||
width: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
.stageImage {
|
||||
border-radius: var(--radius-box);
|
||||
display: block;
|
||||
|
||||
@container (max-width: 599px) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.stageLabel {
|
||||
grid-area: text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.ko {
|
||||
grid-area: ko;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.submit {
|
||||
grid-area: submit;
|
||||
}
|
||||
|
||||
.checkCircle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 100%;
|
||||
border: 2px solid var(--color-border-high);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.teamRadio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-1-5) var(--s-3);
|
||||
border-radius: var(--radius-field);
|
||||
border: 2px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
background-color: var(--color-bg-high);
|
||||
min-width: 160px;
|
||||
transition: background-color 0.15s;
|
||||
height: 100%;
|
||||
|
||||
&:hover .checkCircle {
|
||||
border-color: var(--color-accent-high);
|
||||
}
|
||||
|
||||
@container (max-width: 599px) {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.focusVisible {
|
||||
outline: var(--focus-ring);
|
||||
}
|
||||
|
||||
.teamAvatarInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.checkCircleSelected {
|
||||
background-color: var(--color-accent-high);
|
||||
border-color: var(--color-accent-high);
|
||||
color: var(--color-text-inverse);
|
||||
|
||||
& svg {
|
||||
stroke-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.teamInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.teamName {
|
||||
font-weight: var(--weight-semi);
|
||||
font-size: var(--font-sm);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.teamNameLong {
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.teamLabel {
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.myTeamLabel {
|
||||
color: var(--color-success-high);
|
||||
}
|
||||
|
||||
.opponentLabel {
|
||||
color: var(--color-error-high);
|
||||
}
|
||||
|
||||
.koLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
font-weight: var(--weight-semi);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirmationRoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.confirmationMessage {
|
||||
font-weight: var(--weight-semi);
|
||||
text-align: center;
|
||||
color: var(--color-warning);
|
||||
margin-block-end: var(--s-4);
|
||||
}
|
||||
|
||||
.confirmationButtons {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
margin-block-start: var(--s-4);
|
||||
}
|
||||
334
app/components/match-page/MatchActionTab.tsx
Normal file
334
app/components/match-page/MatchActionTab.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
import clsx from "clsx";
|
||||
import { Check } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { Radio, RadioGroup } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { SendouButton } from "../elements/Button";
|
||||
import { SendouTabPanel } from "../elements/Tabs";
|
||||
import { ModeImage, StageImage } from "../Image";
|
||||
import styles from "./MatchActionTab.module.css";
|
||||
import { TAB_KEYS } from "./MatchTabs";
|
||||
import {
|
||||
MatchTimeline,
|
||||
type MatchTimelineProps,
|
||||
type TimelineMap,
|
||||
} from "./MatchTimeline";
|
||||
import { WeaponReporter, type WeaponReporterProps } from "./WeaponReporter";
|
||||
|
||||
const LONG_TEAM_NAME_THRESHOLD = 16;
|
||||
|
||||
interface ActionTabTeam {
|
||||
id: number;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface SetEndingData extends MatchTimelineProps {
|
||||
currentRosters: { alpha: CommonUser[]; bravo: CommonUser[] };
|
||||
setEndingTeamIds: number[];
|
||||
}
|
||||
|
||||
interface MatchActionTabProps {
|
||||
teams: [ActionTabTeam, ActionTabTeam];
|
||||
ownTeamId: number | null;
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
withPoints: boolean;
|
||||
onSubmit?: (data: { winnerId: number; points?: [number, number] }) => void;
|
||||
isSubmitting?: boolean;
|
||||
setEnding?: SetEndingData;
|
||||
actionButtons?: React.ReactNode;
|
||||
weaponReport?: WeaponReporterProps;
|
||||
}
|
||||
|
||||
export function MatchActionTab({
|
||||
teams,
|
||||
ownTeamId,
|
||||
stageId,
|
||||
mode,
|
||||
withPoints,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
setEnding,
|
||||
actionButtons,
|
||||
weaponReport,
|
||||
}: MatchActionTabProps) {
|
||||
const { t } = useTranslation(["q", "game-misc", "common"]);
|
||||
const [winnerId, setWinnerId] = useState<number | null>(null);
|
||||
const [isKo, setIsKo] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const { trigger } = useWebHaptics();
|
||||
|
||||
const canSubmit = winnerId !== null;
|
||||
|
||||
const isOnTeam =
|
||||
ownTeamId != null &&
|
||||
(teams[0].id === ownTeamId || teams[1].id === ownTeamId);
|
||||
|
||||
const submit = () => {
|
||||
if (winnerId === null) return;
|
||||
const submitPoints: [number, number] | undefined = withPoints
|
||||
? isKo
|
||||
? winnerId === teams[0].id
|
||||
? [100, 0]
|
||||
: [0, 100]
|
||||
: [0, 0]
|
||||
: undefined;
|
||||
onSubmit?.({ winnerId, points: submitPoints });
|
||||
};
|
||||
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.ACTION}>
|
||||
{confirming && winnerId !== null && setEnding ? (
|
||||
<SetEndingConfirmation
|
||||
setEnding={setEnding}
|
||||
stageId={stageId}
|
||||
mode={mode}
|
||||
winnerId={winnerId}
|
||||
teams={teams}
|
||||
withPoints={withPoints}
|
||||
isKo={isKo}
|
||||
isSubmitting={isSubmitting}
|
||||
onBack={() => setConfirming(false)}
|
||||
onConfirm={submit}
|
||||
/>
|
||||
) : (
|
||||
<div className={clsx(styles.root, { [styles.withPoints]: withPoints })}>
|
||||
<div className={styles.title}>{t("q:match.action.selectWinner")}</div>
|
||||
{actionButtons ? (
|
||||
<div className={styles.actionButtons}>{actionButtons}</div>
|
||||
) : null}
|
||||
|
||||
<RadioGroup
|
||||
value={winnerId !== null ? String(winnerId) : null}
|
||||
onChange={(value) => {
|
||||
const selectedId = Number(value);
|
||||
setWinnerId(selectedId);
|
||||
|
||||
const isEnemySelection = isOnTeam && selectedId !== ownTeamId;
|
||||
if (isEnemySelection) {
|
||||
trigger([
|
||||
{ duration: 40, intensity: 0.7 },
|
||||
{ delay: 40, duration: 40, intensity: 0.7 },
|
||||
{ delay: 40, duration: 40, intensity: 0.9 },
|
||||
{ delay: 40, duration: 50, intensity: 0.6 },
|
||||
]);
|
||||
} else {
|
||||
trigger([
|
||||
{ duration: 30 },
|
||||
{ delay: 60, duration: 40, intensity: 1 },
|
||||
]);
|
||||
}
|
||||
}}
|
||||
isDisabled={isSubmitting}
|
||||
aria-label={t("q:match.action.selectWinner")}
|
||||
className={styles.selectionRow}
|
||||
>
|
||||
<TeamRadioOption
|
||||
team={teams[0]}
|
||||
isOwnTeam={teams[0].id === ownTeamId}
|
||||
hideLabel={ownTeamId == null}
|
||||
className={styles.alpha}
|
||||
testId="winner-radio-1"
|
||||
/>
|
||||
<StageImage
|
||||
stageId={stageId}
|
||||
width={90}
|
||||
className={styles.stageImage}
|
||||
containerClassName={styles.stageImageWrapper}
|
||||
/>
|
||||
<div className={styles.stageLabel}>
|
||||
<ModeImage mode={mode} size={14} />
|
||||
<span>{shortStageName(t(`game-misc:STAGE_${stageId}`))}</span>
|
||||
</div>
|
||||
<TeamRadioOption
|
||||
team={teams[1]}
|
||||
isOwnTeam={teams[1].id === ownTeamId}
|
||||
hideLabel={ownTeamId == null}
|
||||
className={clsx(styles.bravo)}
|
||||
testId="winner-radio-2"
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
{withPoints ? (
|
||||
<div className={styles.ko}>
|
||||
<label className={styles.koLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isKo}
|
||||
onChange={(e) => setIsKo(e.target.checked)}
|
||||
data-testid="ko-checkbox"
|
||||
/>
|
||||
{t("q:match.action.ko")}
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SendouButton
|
||||
variant="primary"
|
||||
isDisabled={!canSubmit || isSubmitting}
|
||||
onPress={() => {
|
||||
if (winnerId === null) return;
|
||||
if (setEnding?.setEndingTeamIds.includes(winnerId)) {
|
||||
setConfirming(true);
|
||||
} else {
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
className={styles.submit}
|
||||
testId="report-score-button"
|
||||
>
|
||||
{t("common:actions.submit")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
)}
|
||||
{weaponReport ? <WeaponReporter {...weaponReport} /> : null}
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SetEndingConfirmation({
|
||||
setEnding,
|
||||
stageId,
|
||||
mode,
|
||||
winnerId,
|
||||
teams,
|
||||
withPoints,
|
||||
isKo,
|
||||
isSubmitting,
|
||||
onBack,
|
||||
onConfirm,
|
||||
}: {
|
||||
setEnding: SetEndingData;
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
winnerId: number;
|
||||
teams: [ActionTabTeam, ActionTabTeam];
|
||||
withPoints: boolean;
|
||||
isKo: boolean;
|
||||
isSubmitting?: boolean;
|
||||
onBack: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["q", "common"]);
|
||||
|
||||
const winnerSide = winnerId === teams[0].id ? "ALPHA" : "BRAVO";
|
||||
|
||||
const newMap: TimelineMap = {
|
||||
stageId,
|
||||
mode,
|
||||
timestamp: Date.now(),
|
||||
winner: winnerSide,
|
||||
rosters: setEnding.currentRosters,
|
||||
points: withPoints
|
||||
? isKo
|
||||
? [winnerSide === "ALPHA" ? 100 : 0, winnerSide === "BRAVO" ? 100 : 0]
|
||||
: [0, 0]
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const updatedScore = {
|
||||
alpha: setEnding.score.alpha + (winnerSide === "ALPHA" ? 1 : 0),
|
||||
bravo: setEnding.score.bravo + (winnerSide === "BRAVO" ? 1 : 0),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.confirmationRoot}>
|
||||
<div className={styles.confirmationMessage}>
|
||||
{t("q:match.action.confirmSetEnding")}
|
||||
</div>
|
||||
<MatchTimeline
|
||||
teams={setEnding.teams}
|
||||
score={updatedScore}
|
||||
maps={[...setEnding.maps, newMap]}
|
||||
/>
|
||||
<div className={styles.confirmationButtons}>
|
||||
<SendouButton
|
||||
variant="primary"
|
||||
isDisabled={isSubmitting}
|
||||
onPress={onConfirm}
|
||||
testId="confirm-set-end-button"
|
||||
>
|
||||
{t("common:actions.confirm")}
|
||||
</SendouButton>
|
||||
<SendouButton variant="outlined" onPress={onBack}>
|
||||
{t("common:actions.back")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamRadioOption({
|
||||
team,
|
||||
isOwnTeam,
|
||||
hideLabel,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
team: ActionTabTeam;
|
||||
isOwnTeam: boolean;
|
||||
hideLabel?: boolean;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
const isLongName = team.name.length > LONG_TEAM_NAME_THRESHOLD;
|
||||
|
||||
return (
|
||||
<Radio
|
||||
value={String(team.id)}
|
||||
aria-label={team.name}
|
||||
className={clsx(styles.teamRadioContainer, className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{({ isSelected, isFocusVisible }) => (
|
||||
<span
|
||||
className={clsx(styles.teamRadio, {
|
||||
[styles.selected]: isSelected,
|
||||
[styles.focusVisible]: isFocusVisible,
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={clsx(styles.checkCircle, {
|
||||
[styles.checkCircleSelected]: isSelected,
|
||||
})}
|
||||
>
|
||||
{isSelected ? <Check size={14} /> : null}
|
||||
</span>
|
||||
<span className={styles.teamAvatarInfo}>
|
||||
<Avatar url={team.avatar} identiconInput={team.name} size="xxs" />
|
||||
<span className={styles.teamInfo}>
|
||||
<span
|
||||
className={clsx(styles.teamName, {
|
||||
[styles.teamNameLong]: isLongName,
|
||||
})}
|
||||
>
|
||||
{team.name}
|
||||
</span>
|
||||
{hideLabel ? null : (
|
||||
<span
|
||||
className={clsx(styles.teamLabel, {
|
||||
[styles.myTeamLabel]: isOwnTeam,
|
||||
[styles.opponentLabel]: !isOwnTeam,
|
||||
})}
|
||||
>
|
||||
{isOwnTeam
|
||||
? t("q:match.action.myTeam")
|
||||
: t("q:match.action.opponent")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</Radio>
|
||||
);
|
||||
}
|
||||
157
app/components/match-page/MatchBanner.module.css
Normal file
157
app/components/match-page/MatchBanner.module.css
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
.root {
|
||||
--banner-height: 175px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
grid-template-areas: "map info";
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
width: 100%;
|
||||
height: var(--banner-height);
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-2);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(0, 0, 0, 0.6)
|
||||
),
|
||||
var(--stage-img);
|
||||
color: var(--color-text);
|
||||
|
||||
:global(html.light) & {
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
grid-area: info;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
.map {
|
||||
grid-area: map;
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.notice {
|
||||
position: absolute;
|
||||
bottom: var(--s-2);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: var(--s-0-5);
|
||||
align-items: center;
|
||||
color: var(--color-text-high);
|
||||
background-color: var(--color-bg-high);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: normal;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.infoBadge {
|
||||
display: flex;
|
||||
gap: var(--s-1-5);
|
||||
align-items: center;
|
||||
height: auto;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.thickText {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.legalIcon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.illegalIcon {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.multiBanner {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.segment {
|
||||
--slant: 13px;
|
||||
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(0, 0, 0, 0.6)
|
||||
),
|
||||
var(--stage-img);
|
||||
clip-path: polygon(
|
||||
var(--slant) 0,
|
||||
100% 0,
|
||||
calc(100% - var(--slant)) 100%,
|
||||
0 100%
|
||||
);
|
||||
margin-inline-start: calc(var(--slant) * -1);
|
||||
|
||||
&:first-child {
|
||||
clip-path: polygon(0 0, 100% 0, calc(100% - var(--slant)) 100%, 0 100%);
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
clip-path: polygon(var(--slant) 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.iconBanner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1);
|
||||
width: 100%;
|
||||
height: var(--banner-height);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.iconBannerHeader {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.iconBannerSubtitle {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-low);
|
||||
}
|
||||
|
||||
.iconBannerBottomRight {
|
||||
position: absolute;
|
||||
top: var(--s-2);
|
||||
right: var(--s-2);
|
||||
}
|
||||
144
app/components/match-page/MatchBanner.tsx
Normal file
144
app/components/match-page/MatchBanner.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import clsx from "clsx";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { specialWeaponImageUrl, stageBannerImageUrl } from "~/utils/urls";
|
||||
import { ModeImage } from "../Image";
|
||||
import styles from "./MatchBanner.module.css";
|
||||
|
||||
export function MatchBannerContainer({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className={styles.root}>{children}</div>;
|
||||
}
|
||||
|
||||
interface MatchBannerProps {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
screenLegal?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MatchBanner({
|
||||
stageId,
|
||||
mode,
|
||||
screenLegal,
|
||||
children,
|
||||
}: MatchBannerProps) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.banner}
|
||||
style={{
|
||||
"--stage-img": `url(${stageBannerImageUrl(stageId)})`,
|
||||
}}
|
||||
data-testid="stage-banner"
|
||||
>
|
||||
<div className={clsx(styles.map, styles.thickText)}>
|
||||
<ModeImage mode={mode} size={24} />
|
||||
{t(`game-misc:MODE_SHORT_${mode}`)} {t(`game-misc:STAGE_${stageId}`)}
|
||||
</div>
|
||||
<div className={clsx(styles.info, styles.thickText)}>{children}</div>
|
||||
|
||||
{screenLegal !== undefined ? (
|
||||
<ScreenNotice screenLegal={screenLegal} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiMatchBanner({ stageIds }: { stageIds: StageId[] }) {
|
||||
return (
|
||||
<div className={clsx(styles.banner, styles.multiBanner)}>
|
||||
{stageIds.map((stageId, i) => (
|
||||
<div
|
||||
key={`${stageId}-${i}`}
|
||||
className={styles.segment}
|
||||
style={
|
||||
{
|
||||
"--stage-img": `url(${stageBannerImageUrl(stageId)})`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IconBannerProps {
|
||||
icon: React.ReactNode;
|
||||
header: string;
|
||||
subtitle?: string;
|
||||
screenLegal?: boolean;
|
||||
topRight?: React.ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function IconBanner({
|
||||
icon,
|
||||
header,
|
||||
subtitle,
|
||||
screenLegal,
|
||||
topRight,
|
||||
testId,
|
||||
}: IconBannerProps) {
|
||||
return (
|
||||
<div className={styles.iconBanner} data-testid={testId}>
|
||||
{icon}
|
||||
<div className={styles.iconBannerHeader}>{header}</div>
|
||||
{subtitle ? (
|
||||
<div className={styles.iconBannerSubtitle}>{subtitle}</div>
|
||||
) : null}
|
||||
{screenLegal !== undefined ? (
|
||||
<ScreenNotice screenLegal={screenLegal} />
|
||||
) : null}
|
||||
{topRight ? (
|
||||
<div className={styles.iconBannerBottomRight}>{topRight}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
|
||||
const { t } = useTranslation(["weapons", "q"]);
|
||||
|
||||
const imgSize = 18;
|
||||
|
||||
const Icon = screenLegal ? Check : X;
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
className={styles.notice}
|
||||
testId={screenLegal ? "screen-allowed" : "screen-banned"}
|
||||
>
|
||||
<Icon
|
||||
size={imgSize}
|
||||
className={screenLegal ? styles.legalIcon : styles.illegalIcon}
|
||||
/>
|
||||
<img
|
||||
src={`${specialWeaponImageUrl(19)}.avif`}
|
||||
width={imgSize}
|
||||
height={imgSize}
|
||||
alt=""
|
||||
/>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{screenLegal
|
||||
? t("q:match.screen.allowed", {
|
||||
special: t("weapons:SPECIAL_19"),
|
||||
})
|
||||
: t("q:match.screen.ban", {
|
||||
special: t("weapons:SPECIAL_19"),
|
||||
})}
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
56
app/components/match-page/MatchBannerBottomRow.module.css
Normal file
56
app/components/match-page/MatchBannerBottomRow.module.css
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
.root {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-inline: var(--s-1-5);
|
||||
|
||||
@container (max-width: 500px) {
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
}
|
||||
|
||||
.activeRosters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.modeProgress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.mode {
|
||||
background-color: var(--color-bg-higher);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-1);
|
||||
}
|
||||
|
||||
.modePlaceholder {
|
||||
background-color: transparent;
|
||||
padding: calc(var(--s-1) - 1px);
|
||||
border: 1px dashed var(--color-bg-higher);
|
||||
color: var(--color-text-low);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modeCount {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.team {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.vs {
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
102
app/components/match-page/MatchBannerBottomRow.tsx
Normal file
102
app/components/match-page/MatchBannerBottomRow.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import clsx from "clsx";
|
||||
import { MousePointerClick } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ModeShort } from "~/modules/in-game-lists/types";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { ModeImage } from "../Image";
|
||||
import styles from "./MatchBannerBottomRow.module.css";
|
||||
|
||||
interface MatchBannerBottomRowProps {
|
||||
games: Array<{ mode: ModeShort | null; winner?: "ALPHA" | "BRAVO" }>;
|
||||
activeRosters: {
|
||||
alpha: CommonUser[] | null;
|
||||
bravo: CommonUser[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function MatchBannerBottomRow({
|
||||
games,
|
||||
activeRosters,
|
||||
}: MatchBannerBottomRowProps) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<ModeProgress games={games} />
|
||||
<ActiveRosters activeRosters={activeRosters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeProgress({ games }: Pick<MatchBannerBottomRowProps, "games">) {
|
||||
const knownModes = games.flatMap((game) => (game.mode ? [game.mode] : []));
|
||||
const allSameMode =
|
||||
knownModes.length === games.length &&
|
||||
games.length > 1 &&
|
||||
knownModes.every((mode) => mode === knownModes[0]);
|
||||
|
||||
if (allSameMode) {
|
||||
return (
|
||||
<div className={styles.modeProgress}>
|
||||
<div
|
||||
className={styles.mode}
|
||||
data-testid={`mode-progress-${knownModes[0]}`}
|
||||
>
|
||||
<ModeImage mode={knownModes[0]} size={16} />
|
||||
</div>
|
||||
<div className={styles.modeCount}>×{games.length}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.modeProgress}>
|
||||
{games.map((game, i) =>
|
||||
game.mode ? (
|
||||
<div
|
||||
key={i}
|
||||
className={styles.mode}
|
||||
data-testid={`mode-progress-${game.mode}`}
|
||||
>
|
||||
<ModeImage mode={game.mode} size={16} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(styles.mode, styles.modePlaceholder)}
|
||||
data-testid="mode-progress-banned"
|
||||
>
|
||||
<MousePointerClick size={16} />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Roster({ users }: { users: CommonUser[] }) {
|
||||
return (
|
||||
<div className={styles.team}>
|
||||
{users.map((user) => (
|
||||
<Avatar key={user.id} user={user} size="xxs" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveRosters({
|
||||
activeRosters,
|
||||
}: Pick<MatchBannerBottomRowProps, "activeRosters">) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
if (!activeRosters?.alpha || !activeRosters.bravo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.activeRosters}>
|
||||
<Roster users={activeRosters.alpha} />
|
||||
<div className={styles.vs}>{t("q:match.banner.vs")}</div>
|
||||
<Roster users={activeRosters.bravo} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
app/components/match-page/MatchBannerTopRow.module.css
Normal file
15
app/components/match-page/MatchBannerTopRow.module.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.root {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-inline: var(--s-1-5);
|
||||
}
|
||||
|
||||
.values {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
88
app/components/match-page/MatchBannerTopRow.tsx
Normal file
88
app/components/match-page/MatchBannerTopRow.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import styles from "./MatchBannerTopRow.module.css";
|
||||
|
||||
interface MatchBannerTopRowProps {
|
||||
score: {
|
||||
alpha: number;
|
||||
bravo: number;
|
||||
isFinal: boolean;
|
||||
count: number;
|
||||
bestOf: boolean;
|
||||
};
|
||||
time?: {
|
||||
currentMinutes: number;
|
||||
totalMinutes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function MatchBannerTopRow({ score, time }: MatchBannerTopRowProps) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Score score={score} />
|
||||
{time ? <Timer time={time} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Score({ score }: { score: MatchBannerTopRowProps["score"] }) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<div className={styles.values}>
|
||||
<div>
|
||||
{score.alpha}-{score.bravo}
|
||||
</div>
|
||||
<div
|
||||
className={styles.sub}
|
||||
data-testid={score.isFinal ? "match-final" : undefined}
|
||||
>
|
||||
{score.isFinal
|
||||
? t("q:match.banner.final")
|
||||
: score.bestOf
|
||||
? t("q:match.banner.bestOf", { count: score.count })
|
||||
: t("q:match.banner.playAll", { count: score.count })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Timer({
|
||||
time,
|
||||
}: {
|
||||
time: NonNullable<MatchBannerTopRowProps["time"]>;
|
||||
}) {
|
||||
const isHydrated = useHydrated();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
if (!isHydrated) return null;
|
||||
|
||||
const minuteFormatter = new Intl.NumberFormat(i18n.language, {
|
||||
style: "unit",
|
||||
unit: "minute",
|
||||
unitDisplay: "short",
|
||||
});
|
||||
const hourFormatter = new Intl.NumberFormat(i18n.language, {
|
||||
style: "unit",
|
||||
unit: "hour",
|
||||
unitDisplay: "short",
|
||||
});
|
||||
|
||||
const MAX_MINUTES = 60;
|
||||
const dateTime = (minutes: number) => `PT0H${minutes}M`;
|
||||
const displayValue = (minutes: number) =>
|
||||
minutes >= MAX_MINUTES
|
||||
? `${hourFormatter.format(1)}+`
|
||||
: minuteFormatter.format(minutes);
|
||||
|
||||
return (
|
||||
<div className={styles.values} data-testid="match-timer">
|
||||
<time dateTime={dateTime(time.currentMinutes)} className={styles.sub}>
|
||||
{displayValue(time.currentMinutes)}
|
||||
</time>
|
||||
<time dateTime={dateTime(time.totalMinutes)}>
|
||||
{displayValue(time.totalMinutes)}
|
||||
</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
app/components/match-page/MatchJoinTab.module.css
Normal file
86
app/components/match-page/MatchJoinTab.module.css
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
.joinContent {
|
||||
display: grid;
|
||||
grid-template-areas: "time x" "qr join";
|
||||
gap: var(--s-1) var(--s-4);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.joinInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: join;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.infoHeader {
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-semi);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.qrCodeContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
grid-area: qr;
|
||||
}
|
||||
|
||||
.roomAge {
|
||||
grid-area: time;
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qrCode {
|
||||
background-color: white;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.joinLink {
|
||||
font-size: var(--font-2xs);
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: hidden;
|
||||
text-wrap: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.qrOverlay {
|
||||
width: 172px;
|
||||
height: 172px;
|
||||
border-radius: var(--radius-field);
|
||||
padding: var(--s-2);
|
||||
background-color: var(--color-bg-higher);
|
||||
grid-area: qr;
|
||||
}
|
||||
|
||||
.stalePrompt {
|
||||
display: flex;
|
||||
gap: var(--s-6);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.staleText {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noRoomHint {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
146
app/components/match-page/MatchJoinTab.tsx
Normal file
146
app/components/match-page/MatchJoinTab.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import clsx from "clsx";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { SendouButton } from "../elements/Button";
|
||||
import { SendouTabPanel } from "../elements/Tabs";
|
||||
import styles from "./MatchJoinTab.module.css";
|
||||
import { TAB_KEYS } from "./MatchTabs";
|
||||
|
||||
interface MatchJoinTabProps {
|
||||
joinLink?: string;
|
||||
hostedBy?: string;
|
||||
pool: string;
|
||||
pass: string;
|
||||
showNoSplatnetAlert: boolean;
|
||||
isStale?: boolean;
|
||||
staleMinutesAgo?: number;
|
||||
refreshedAt?: Date;
|
||||
onConfirmRoom?: () => void;
|
||||
isConfirming?: boolean;
|
||||
}
|
||||
|
||||
export function MatchJoinTab({
|
||||
joinLink,
|
||||
hostedBy,
|
||||
pool,
|
||||
pass,
|
||||
showNoSplatnetAlert,
|
||||
isStale,
|
||||
staleMinutesAgo,
|
||||
refreshedAt,
|
||||
onConfirmRoom,
|
||||
isConfirming,
|
||||
}: MatchJoinTabProps) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const { formatDistanceToNow } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.JOIN}>
|
||||
<div className="stack lg">
|
||||
{showNoSplatnetAlert ? (
|
||||
<Alert variation="WARNING" tiny>
|
||||
{t("q:match.noSplatnetWarning")}
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className={styles.joinContent}>
|
||||
{joinLink ? (
|
||||
isStale ? (
|
||||
<StaleRoomPrompt
|
||||
minutesAgo={staleMinutesAgo ?? 0}
|
||||
onConfirm={onConfirmRoom}
|
||||
isConfirming={isConfirming}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{refreshedAt ? (
|
||||
<div className={styles.roomAge}>
|
||||
{formatDistanceToNow(refreshedAt, { addSuffix: true })}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.qrCodeContainer}>
|
||||
<QRCodeSVG
|
||||
value={joinLink}
|
||||
size={140}
|
||||
className={styles.qrCode}
|
||||
/>
|
||||
<a
|
||||
href={joinLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.joinLink}
|
||||
>
|
||||
{joinLink}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className={clsx(styles.qrOverlay, styles.noRoomHint)}>
|
||||
{t("q:match.room.noRoomHint")}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.joinInfo}>
|
||||
{hostedBy ? (
|
||||
<InfoWithHeader header={t("q:match.hostedBy")} value={hostedBy} />
|
||||
) : null}
|
||||
<InfoWithHeader header={t("q:match.pool")} value={pool} />
|
||||
<InfoWithHeader
|
||||
header={t("q:match.password.short")}
|
||||
value={pass}
|
||||
testId="room-pass"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function StaleRoomPrompt({
|
||||
minutesAgo,
|
||||
onConfirm,
|
||||
isConfirming,
|
||||
}: {
|
||||
minutesAgo: number;
|
||||
onConfirm?: () => void;
|
||||
isConfirming?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.qrOverlay, styles.stalePrompt)}>
|
||||
<div className={styles.staleText}>
|
||||
{t("q:match.room.stalePrompt", { minutes: minutesAgo })}
|
||||
</div>
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onPress={onConfirm}
|
||||
isDisabled={isConfirming}
|
||||
>
|
||||
{t("q:match.room.confirm")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoWithHeader({
|
||||
header,
|
||||
value,
|
||||
testId,
|
||||
}: {
|
||||
header: string;
|
||||
value: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.infoHeader}>{header}</div>
|
||||
<div className={styles.infoValue} data-testid={testId}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
app/components/match-page/MatchPage.module.css
Normal file
5
app/components/match-page/MatchPage.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-6);
|
||||
}
|
||||
12
app/components/match-page/MatchPage.tsx
Normal file
12
app/components/match-page/MatchPage.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import clsx from "clsx";
|
||||
import styles from "./MatchPage.module.css";
|
||||
|
||||
export function MatchPage({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={clsx(styles.root, className)}>{children}</div>;
|
||||
}
|
||||
14
app/components/match-page/MatchPageHeader.module.css
Normal file
14
app/components/match-page/MatchPageHeader.module.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
.root {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
21
app/components/match-page/MatchPageHeader.tsx
Normal file
21
app/components/match-page/MatchPageHeader.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import styles from "./MatchPageHeader.module.css";
|
||||
|
||||
export function MatchPageHeader({
|
||||
children,
|
||||
subtitle,
|
||||
topRight,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
subtitle: string;
|
||||
topRight?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div>
|
||||
<h2 className={styles.title}>{children}</h2>
|
||||
<div className={styles.subtitle}>{subtitle}</div>
|
||||
</div>
|
||||
{topRight ? <div>{topRight}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app/components/match-page/MatchResultTab.tsx
Normal file
16
app/components/match-page/MatchResultTab.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type * as React from "react";
|
||||
import { SendouTabPanel } from "../elements/Tabs";
|
||||
import { TAB_KEYS } from "./MatchTabs";
|
||||
import { MatchTimeline, type MatchTimelineProps } from "./MatchTimeline";
|
||||
|
||||
export function MatchResultTab({
|
||||
children,
|
||||
...props
|
||||
}: MatchTimelineProps & { children?: React.ReactNode }) {
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.RESULT}>
|
||||
<MatchTimeline {...props} />
|
||||
{children}
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
258
app/components/match-page/MatchRosterTab.module.css
Normal file
258
app/components/match-page/MatchRosterTab.module.css
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
.rosters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-8);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.rostersDivider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@container (width >= 640px) {
|
||||
.rosters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: var(--s-4);
|
||||
width: auto;
|
||||
max-width: none;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.rosterColumn {
|
||||
margin-inline: auto;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.rostersDivider {
|
||||
display: block;
|
||||
background-color: var(--color-border);
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.rostersSpacedHeader {
|
||||
min-height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rosterMembers {
|
||||
position: relative;
|
||||
padding-inline-start: 34px;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2-5);
|
||||
margin-top: var(--s-2);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-inline-start: 21px;
|
||||
top: -8px;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background-color: var(--color-border-high);
|
||||
opacity: 0.3;
|
||||
border-radius: 0 0 var(--radius-field) var(--radius-field);
|
||||
}
|
||||
}
|
||||
|
||||
.tierBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 100%;
|
||||
background-color: var(--color-bg-higher);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tierPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.tierPopoverName {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.memberGrid {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"avatar name"
|
||||
"tier meta";
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: var(--s-2);
|
||||
row-gap: var(--s-1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.memberLink {
|
||||
grid-row: 1;
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
column-gap: var(--s-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.memberNameStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.memberInGameName {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.memberMenuTrigger {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.friendCodeHeader {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.memberMenuHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.memberMenuIgn {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.memberMenuIgnLabel {
|
||||
font-weight: var(--weight-bold);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-3xs);
|
||||
}
|
||||
|
||||
.memberTier {
|
||||
grid-area: tier;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.memberMetaArea {
|
||||
grid-area: meta;
|
||||
}
|
||||
|
||||
.memberMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.plusTier {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
background-color: var(--color-bg-higher);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
padding-inline-start: var(--s-1);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.subbedOutTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.subbedOutIcon {
|
||||
--subbed-out-icon-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--subbed-out-icon-size);
|
||||
height: var(--subbed-out-icon-size);
|
||||
border-radius: 100%;
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.subbedOutPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.subbedOutHeader {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rosterEditCount {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
margin-block-end: var(--s-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rosterEditButtons {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
margin-block-start: var(--s-4);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.teamOneDot {
|
||||
border-radius: 100%;
|
||||
background-color: var(--color-accent);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.teamTwoDot {
|
||||
border-radius: 100%;
|
||||
background-color: var(--color-second);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.teamAvatar {
|
||||
border-radius: var(--radius-avatar);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&[data-side="alpha"] {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
&[data-side="bravo"] {
|
||||
background-color: var(--color-second);
|
||||
}
|
||||
}
|
||||
530
app/components/match-page/MatchRosterTab.tsx
Normal file
530
app/components/match-page/MatchRosterTab.tsx
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
import clsx from "clsx";
|
||||
import { Armchair, Edit, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button as ReactAriaButton } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import {
|
||||
SendouMenu,
|
||||
SendouMenuItem,
|
||||
SendouMenuSection,
|
||||
} from "~/components/elements/Menu";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { Image, TierImage } from "~/components/Image";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import {
|
||||
navIconUrl,
|
||||
preferenceEmojiUrl,
|
||||
tierImageUrl,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { SendouTabPanel } from "../elements/Tabs";
|
||||
import styles from "./MatchRosterTab.module.css";
|
||||
import { TAB_KEYS } from "./MatchTabs";
|
||||
import { WeaponPool } from "./WeaponPool";
|
||||
|
||||
type RosterTabMember = CommonUser & {
|
||||
tier?: { name: TierName; isPlus: boolean } | "CALCULATING";
|
||||
plusTier?: number | null;
|
||||
weaponPool?: Array<MainWeaponId>;
|
||||
friendCode?: string | null;
|
||||
privateNote?: { sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE" } | null;
|
||||
inGameName?: string | null;
|
||||
};
|
||||
|
||||
interface RosterTabTeam {
|
||||
team?: {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
avatar?: string;
|
||||
};
|
||||
defaultName?: string;
|
||||
members: Array<RosterTabMember>;
|
||||
/** Sub user ids i.e. those who are not the current active roster */
|
||||
subbedOut?: Array<number>;
|
||||
tier?: { name: TierName; isPlus: boolean };
|
||||
}
|
||||
|
||||
interface MatchRosterTabProps {
|
||||
teams: [RosterTabTeam, RosterTabTeam];
|
||||
minMembersPerTeam: number;
|
||||
canEditSubbedOut?: [boolean, boolean];
|
||||
defaultIsEditing?: [boolean, boolean];
|
||||
onSubbedOutChange?: (teamId: number, subbedOut: number[]) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export function MatchRosterTab({
|
||||
teams,
|
||||
minMembersPerTeam,
|
||||
canEditSubbedOut,
|
||||
defaultIsEditing,
|
||||
onSubbedOutChange,
|
||||
isSubmitting,
|
||||
}: MatchRosterTabProps) {
|
||||
return (
|
||||
<SendouTabPanel id={TAB_KEYS.ROSTERS}>
|
||||
<div className={styles.rosters}>
|
||||
<TeamRoster
|
||||
team={teams[0]}
|
||||
side="alpha"
|
||||
canEditSubbedOut={canEditSubbedOut?.[0] ?? false}
|
||||
defaultIsEditing={defaultIsEditing?.[0] ?? false}
|
||||
minMembersPerTeam={minMembersPerTeam}
|
||||
onSubbedOutChange={onSubbedOutChange}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<div className={styles.rostersDivider} />
|
||||
<TeamRoster
|
||||
team={teams[1]}
|
||||
side="bravo"
|
||||
canEditSubbedOut={canEditSubbedOut?.[1] ?? false}
|
||||
defaultIsEditing={defaultIsEditing?.[1] ?? false}
|
||||
minMembersPerTeam={minMembersPerTeam}
|
||||
onSubbedOutChange={onSubbedOutChange}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</SendouTabPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamRoster({
|
||||
team,
|
||||
side,
|
||||
canEditSubbedOut,
|
||||
defaultIsEditing,
|
||||
minMembersPerTeam,
|
||||
onSubbedOutChange,
|
||||
isSubmitting,
|
||||
}: {
|
||||
team: RosterTabTeam;
|
||||
side: "alpha" | "bravo";
|
||||
canEditSubbedOut: boolean;
|
||||
defaultIsEditing: boolean;
|
||||
minMembersPerTeam: number;
|
||||
onSubbedOutChange?: (teamId: number, subbedOut: number[]) => void;
|
||||
isSubmitting?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["common", "q"]);
|
||||
const [isEditing, setIsEditing] = useState(defaultIsEditing);
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<number[]>([]);
|
||||
|
||||
const dotClassName = side === "alpha" ? styles.teamOneDot : styles.teamTwoDot;
|
||||
const label =
|
||||
side === "alpha" ? t("q:match.sides.alpha") : t("q:match.sides.bravo");
|
||||
|
||||
const subbedOutSet = new Set(team.subbedOut);
|
||||
const activeMembers = team.members.filter(
|
||||
(member) => !subbedOutSet.has(member.id),
|
||||
);
|
||||
const subbedOutMembers = team.members.filter((member) =>
|
||||
subbedOutSet.has(member.id),
|
||||
);
|
||||
|
||||
const showEditButton = canEditSubbedOut && team.team && !isEditing;
|
||||
|
||||
return (
|
||||
<div className={clsx("stack xxs", styles.rosterColumn)}>
|
||||
<TeamHeader
|
||||
team={team}
|
||||
side={side}
|
||||
label={label}
|
||||
dotClassName={dotClassName}
|
||||
/>
|
||||
{team.members.length > 0 ? (
|
||||
<ul className={styles.rosterMembers}>
|
||||
{isEditing
|
||||
? team.members.map((member, index) => (
|
||||
<li key={member.id}>
|
||||
<label className="stack horizontal sm items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMemberIds.includes(member.id)}
|
||||
onChange={() => handleToggleMember(member.id)}
|
||||
data-testid={`player-checkbox-${side}-${index}`}
|
||||
/>
|
||||
<Avatar user={member} size="xxs" />
|
||||
<span>{member.username}</span>
|
||||
</label>
|
||||
</li>
|
||||
))
|
||||
: activeMembers.map((member) => (
|
||||
<li key={member.id} className={styles.memberGrid}>
|
||||
<RosterMemberLink
|
||||
member={member}
|
||||
className={styles.memberLink}
|
||||
/>
|
||||
<div className={styles.memberTier}>
|
||||
<MemberTierPopover tier={member.tier} />
|
||||
</div>
|
||||
<div className={styles.memberMetaArea}>
|
||||
<MemberMeta
|
||||
plusTier={member.plusTier}
|
||||
weaponPool={member.weaponPool}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{!isEditing && subbedOutMembers.length > 0 ? (
|
||||
<li>
|
||||
<SubbedOutPopover members={subbedOutMembers} />
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<div className={styles.rosterEditCount}>
|
||||
{selectedMemberIds.length}/{minMembersPerTeam}
|
||||
</div>
|
||||
<div className={styles.rosterEditButtons}>
|
||||
<SendouButton
|
||||
variant="primary"
|
||||
size="small"
|
||||
isDisabled={
|
||||
isSubmitting || selectedMemberIds.length !== minMembersPerTeam
|
||||
}
|
||||
onPress={handleSubmit}
|
||||
testId={`save-active-roster-button-${side}`}
|
||||
>
|
||||
{t("common:actions.submit")}
|
||||
</SendouButton>
|
||||
{defaultIsEditing ? null : (
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onPress={handleCancel}
|
||||
>
|
||||
{t("common:actions.cancel")}
|
||||
</SendouButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{showEditButton ? (
|
||||
<SendouButton
|
||||
icon={<Edit />}
|
||||
className="mt-4 mx-auto"
|
||||
size="small"
|
||||
onPress={() => {
|
||||
setSelectedMemberIds(activeMembers.map((m) => m.id));
|
||||
setIsEditing(true);
|
||||
}}
|
||||
testId={`edit-active-roster-button-${side}`}
|
||||
>
|
||||
{t("common:actions.edit")}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleToggleMember(memberId: number) {
|
||||
setSelectedMemberIds((prev) =>
|
||||
prev.includes(memberId)
|
||||
? prev.filter((id) => id !== memberId)
|
||||
: [...prev, memberId],
|
||||
);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!team.team || !onSubbedOutChange) return;
|
||||
|
||||
const subbedOutIds = team.members
|
||||
.filter((m) => !selectedMemberIds.includes(m.id))
|
||||
.map((m) => m.id);
|
||||
onSubbedOutChange(team.team.id, subbedOutIds);
|
||||
setIsEditing(false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setSelectedMemberIds(activeMembers.map((m) => m.id));
|
||||
setIsEditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function TeamHeader({
|
||||
team,
|
||||
side,
|
||||
label,
|
||||
dotClassName,
|
||||
}: {
|
||||
team: RosterTabTeam;
|
||||
side: "alpha" | "bravo";
|
||||
label: string;
|
||||
dotClassName: string;
|
||||
}) {
|
||||
const tierText = team.tier
|
||||
? `${team.tier.name.toLowerCase()}${team.tier.isPlus ? "+" : ""}`
|
||||
: undefined;
|
||||
|
||||
if (team.team) {
|
||||
return (
|
||||
<Link to={team.team.url} className="stack horizontal sm">
|
||||
<Avatar
|
||||
url={team.team.avatar}
|
||||
identiconInput={team.team.name}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="stack justify-center line-height-tight">
|
||||
<h2 className="text-main-forced font-bold">{team.team.name}</h2>
|
||||
<div className="stack xs horizontal items-center text-lighter">
|
||||
<div className={dotClassName} />
|
||||
{label}
|
||||
{tierText ? (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-capitalize">{tierText}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
invariant(team.defaultName, "team or defaultName must be provided");
|
||||
|
||||
return (
|
||||
<div className="stack horizontal sm">
|
||||
<div className={styles.teamAvatar} data-side={side} />
|
||||
<div className="stack justify-center line-height-tight">
|
||||
<h2 className="text-main-forced font-bold">{team.defaultName}</h2>
|
||||
<div className="stack xs horizontal items-center text-lighter">
|
||||
<div className={dotClassName} />
|
||||
{label}
|
||||
{tierText ? (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-capitalize">{tierText}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberTierPopover({
|
||||
tier,
|
||||
}: {
|
||||
tier?: { name: TierName; isPlus: boolean } | "CALCULATING";
|
||||
}) {
|
||||
if (!tier) return null;
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal" className={styles.tierBadge}>
|
||||
{tier === "CALCULATING" ? (
|
||||
<Image
|
||||
path={tierImageUrl("CALCULATING")}
|
||||
alt=""
|
||||
width={22}
|
||||
height={22 * 0.8675}
|
||||
/>
|
||||
) : (
|
||||
<TierImage tier={tier} width={22} />
|
||||
)}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<MemberTierPopoverContent tier={tier} />
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberTierPopoverContent({
|
||||
tier,
|
||||
}: {
|
||||
tier: { name: TierName; isPlus: boolean } | "CALCULATING";
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
if (tier === "CALCULATING") {
|
||||
return (
|
||||
<div className={styles.tierPopover}>
|
||||
<Image
|
||||
path={tierImageUrl("CALCULATING")}
|
||||
alt=""
|
||||
width={80}
|
||||
height={80 * 0.8675}
|
||||
/>
|
||||
<span className={styles.tierPopoverName}>
|
||||
{t("q:looking.sp.calculating")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tierPopover}>
|
||||
<TierImage tier={tier} width={80} />
|
||||
<span className={styles.tierPopoverName}>
|
||||
{tier.name.toLowerCase()}
|
||||
{tier.isPlus ? "+" : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberMeta({
|
||||
plusTier,
|
||||
weaponPool,
|
||||
}: {
|
||||
plusTier?: number | null;
|
||||
weaponPool?: Array<MainWeaponId>;
|
||||
}) {
|
||||
const hasPlusTier = typeof plusTier === "number";
|
||||
const hasWeapons = weaponPool && weaponPool.length > 0;
|
||||
|
||||
if (!hasPlusTier && !hasWeapons) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.memberMeta}>
|
||||
{hasPlusTier ? (
|
||||
<div className={styles.plusTier}>
|
||||
<Image path={navIconUrl("plus")} width={16} height={16} alt="" />
|
||||
<span>{plusTier}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{hasWeapons ? <WeaponPool weapons={weaponPool} size={18} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubbedOutPopover({ members }: { members: Array<RosterTabMember> }) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal" size="small" className="h-max">
|
||||
<div className={styles.subbedOutTrigger}>
|
||||
<div className={styles.subbedOutIcon}>
|
||||
<Armchair size={16} />
|
||||
</div>
|
||||
+{members.length}
|
||||
</div>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<div className={styles.subbedOutPopover}>
|
||||
<div className={styles.subbedOutHeader}>{t("q:match.subbedOut")}</div>
|
||||
{members.map((member) => (
|
||||
<RosterMemberLink
|
||||
key={member.id}
|
||||
member={member}
|
||||
className="stack horizontal sm items-center"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function RosterMemberLink({
|
||||
member,
|
||||
className,
|
||||
}: {
|
||||
member: RosterTabMember;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["friends", "q", "user"]);
|
||||
|
||||
const showNoteItem = member.privateNote !== undefined;
|
||||
const hasContentBelowName = !!(
|
||||
member.tier ||
|
||||
typeof member.plusTier === "number" ||
|
||||
(member.weaponPool && member.weaponPool.length > 0)
|
||||
);
|
||||
const showIgnInMenu = hasContentBelowName && !!member.inGameName;
|
||||
const showIgnUnderName = !hasContentBelowName && !!member.inGameName;
|
||||
const useMenu = !!member.friendCode || showNoteItem || showIgnInMenu;
|
||||
|
||||
const nameContent = (
|
||||
<div className={styles.memberNameStack}>
|
||||
<span>{member.username}</span>
|
||||
{showIgnUnderName ? (
|
||||
<span className={styles.memberInGameName}>{member.inGameName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!useMenu) {
|
||||
return (
|
||||
<Link to={userPage(member)} className={className}>
|
||||
<Avatar user={member} size="xxs" />
|
||||
{nameContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const headerContent =
|
||||
member.friendCode || showIgnInMenu ? (
|
||||
<div className={styles.memberMenuHeader}>
|
||||
{member.friendCode ? <span>{`SW-${member.friendCode}`}</span> : null}
|
||||
{showIgnInMenu ? (
|
||||
<span className={styles.memberMenuIgn}>
|
||||
<span className={styles.memberMenuIgnLabel}>
|
||||
{t("user:ign.short")}:
|
||||
</span>{" "}
|
||||
{member.inGameName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<SendouMenu
|
||||
trigger={
|
||||
<ReactAriaButton className={clsx(className, styles.memberMenuTrigger)}>
|
||||
<Avatar user={member} size="xxs" />
|
||||
{nameContent}
|
||||
</ReactAriaButton>
|
||||
}
|
||||
>
|
||||
<SendouMenuSection
|
||||
headerText={headerContent}
|
||||
headerClassName={styles.friendCodeHeader}
|
||||
>
|
||||
<SendouMenuItem href={userPage(member)} icon={<User />}>
|
||||
{t("friends:friendsList.viewUserPage")}
|
||||
</SendouMenuItem>
|
||||
{showNoteItem ? (
|
||||
<SendouMenuItem
|
||||
href={`?note=${member.id}`}
|
||||
icon={
|
||||
member.privateNote ? (
|
||||
<img
|
||||
src={preferenceEmojiUrl(
|
||||
member.privateNote.sentiment === "POSITIVE"
|
||||
? "PREFER"
|
||||
: member.privateNote.sentiment === "NEGATIVE"
|
||||
? "AVOID"
|
||||
: undefined,
|
||||
)}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
) : (
|
||||
<Edit />
|
||||
)
|
||||
}
|
||||
>
|
||||
{member.privateNote
|
||||
? t("q:looking.groups.editNote")
|
||||
: t("q:looking.groups.addNote")}
|
||||
</SendouMenuItem>
|
||||
) : null}
|
||||
</SendouMenuSection>
|
||||
</SendouMenu>
|
||||
);
|
||||
}
|
||||
7
app/components/match-page/MatchTabs.module.css
Normal file
7
app/components/match-page/MatchTabs.module.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.root {
|
||||
& [class*="tabPanel"] {
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: 0 0 var(--radius-box) var(--radius-box);
|
||||
padding: var(--s-6) var(--s-4);
|
||||
}
|
||||
}
|
||||
76
app/components/match-page/MatchTabs.tsx
Normal file
76
app/components/match-page/MatchTabs.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { DoorOpen, Key, ScrollText, Tally5, Users } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { SendouTab, SendouTabList, SendouTabs } from "../elements/Tabs";
|
||||
import styles from "./MatchTabs.module.css";
|
||||
|
||||
type MatchTabsKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS];
|
||||
interface MatchTabsProps {
|
||||
children: React.ReactNode;
|
||||
tabs: Array<MatchTabsKey>;
|
||||
}
|
||||
|
||||
const TAB_KEY = "tab";
|
||||
|
||||
export const TAB_KEYS = {
|
||||
ROSTERS: "rosters",
|
||||
ACTION: "action",
|
||||
JOIN: "join",
|
||||
RESULT: "result",
|
||||
ADMIN: "admin",
|
||||
} as const;
|
||||
|
||||
const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
|
||||
rosters: <Users />,
|
||||
action: <Tally5 />,
|
||||
join: <DoorOpen />,
|
||||
result: <ScrollText />,
|
||||
admin: <Key />,
|
||||
};
|
||||
|
||||
const TAB_TRANSLATION_KEYS = {
|
||||
rosters: "q:match.tabs.rosters",
|
||||
action: "q:match.tabs.action",
|
||||
join: "common:actions.join",
|
||||
result: "q:match.tabs.result",
|
||||
admin: "common:pages.admin",
|
||||
} as const;
|
||||
|
||||
export function MatchTabs({ children, tabs }: MatchTabsProps) {
|
||||
const { t } = useTranslation(["q", "common"]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const currentTab =
|
||||
tabs.find((tab) => searchParams.get(TAB_KEY) === tab) ?? tabs.at(0);
|
||||
invariant(currentTab);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<SendouTabs
|
||||
selectedKey={currentTab}
|
||||
onSelectionChange={(key) =>
|
||||
setSearchParams(
|
||||
{ [TAB_KEY]: key as string },
|
||||
{
|
||||
preventScrollReset: true,
|
||||
unstable_defaultShouldRevalidate: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
disappearing={false}
|
||||
>
|
||||
<SendouTabList>
|
||||
{tabs.map((tab) => (
|
||||
<SendouTab key={tab} id={tab} icon={TAB_ICONS[tab]}>
|
||||
{t(TAB_TRANSLATION_KEYS[tab])}
|
||||
</SendouTab>
|
||||
))}
|
||||
</SendouTabList>
|
||||
|
||||
{children}
|
||||
</SendouTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
app/components/match-page/MatchTimeline.module.css
Normal file
289
app/components/match-page/MatchTimeline.module.css
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
row-gap: var(--s-6);
|
||||
column-gap: var(--s-4);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.headerTeam {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.headerTeamBravo {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.headerTeamName {
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-md);
|
||||
text-box: trim-start cap alphabetic;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.headerTeamNameLong {
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.headerAvatars {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.headerScore {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerScoreValue {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--weight-extra);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.headerScoreLive {
|
||||
margin-top: var(--s-1);
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.mapEvent {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.mapSide {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
align-self: stretch;
|
||||
|
||||
&:first-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.mapCenter {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
justify-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.mapTimestamp {
|
||||
font-size: var(--font-3xs);
|
||||
color: var(--color-text-high);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.mapStageImage {
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.mapLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.sideResult {
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-2-5);
|
||||
}
|
||||
|
||||
.resultHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.resultLabel {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-extra);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.resultPoints {
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.eventRow {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.eventAlpha {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.subCenter {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eventIcon {
|
||||
color: var(--color-text-high);
|
||||
background-color: var(--color-bg-higher);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-1);
|
||||
}
|
||||
|
||||
.subDetail {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
align-items: center;
|
||||
row-gap: var(--s-1);
|
||||
column-gap: var(--s-3);
|
||||
}
|
||||
|
||||
.subLabelOut {
|
||||
color: var(--color-error);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-3xs);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subLabelIn {
|
||||
color: var(--color-success);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-3xs);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subPlayerName {
|
||||
font-weight: var(--weight-semi);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.pickIcon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.banIcon {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.pickBanGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1-5);
|
||||
justify-content: flex-end;
|
||||
|
||||
&.pickBanGroupBravo {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.pickBanStageImage {
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.pickBanModeTile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
background-color: var(--color-bg-higher);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.spSection {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
column-gap: var(--s-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spIcon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spColumn {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
row-gap: var(--s-1-5);
|
||||
column-gap: var(--s-2);
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
|
||||
&:first-child {
|
||||
justify-content: end;
|
||||
}
|
||||
}
|
||||
|
||||
.spDetail {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--font-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spDetailContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.spDeltaTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: var(--s-0-5) var(--s-1);
|
||||
border-radius: var(--radius-field);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spRawPopover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.spCalculatingIcon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.spTeamIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg-higher);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
633
app/components/match-page/MatchTimeline.tsx
Normal file
633
app/components/match-page/MatchTimeline.tsx
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
import clsx from "clsx";
|
||||
import {
|
||||
ArrowRight,
|
||||
MousePointerClick,
|
||||
RefreshCcw,
|
||||
TrendingUp,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { GroupSkillDifference, UserSkillDifference } from "~/db/tables";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import { roundToNDecimalPlaces } from "~/utils/number";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { SendouButton } from "../elements/Button";
|
||||
import { SendouPopover } from "../elements/Popover";
|
||||
import { ModeImage, StageImage } from "../Image";
|
||||
import styles from "./MatchTimeline.module.css";
|
||||
import { type InferredSubstitution, inferSubstitutions } from "./utils";
|
||||
import { WeaponPool } from "./WeaponPool";
|
||||
|
||||
const LONG_TEAM_NAME_THRESHOLD = 16;
|
||||
|
||||
type MatchSide = "ALPHA" | "BRAVO";
|
||||
|
||||
export interface TimelineTeam {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface TimelineMap {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
timestamp: number;
|
||||
winner: MatchSide;
|
||||
rosters: {
|
||||
alpha: CommonUser[];
|
||||
bravo: CommonUser[];
|
||||
};
|
||||
weapons?: {
|
||||
alpha: Array<MainWeaponId | null>;
|
||||
bravo: Array<MainWeaponId | null>;
|
||||
};
|
||||
/** Optional point values [alpha, bravo] */
|
||||
points?: [number, number];
|
||||
/** Side that picked this map (counterpick / postGame map PICK). Renders a click indicator next to that side's WIN/LOSS label. */
|
||||
pickedBy?: MatchSide;
|
||||
}
|
||||
|
||||
interface TimelineSpMember {
|
||||
user: CommonUser;
|
||||
skillDifference: UserSkillDifference;
|
||||
}
|
||||
|
||||
export interface TimelineSpChanges {
|
||||
alpha: {
|
||||
members: TimelineSpMember[];
|
||||
skillDifference?: GroupSkillDifference;
|
||||
};
|
||||
bravo: {
|
||||
members: TimelineSpMember[];
|
||||
skillDifference?: GroupSkillDifference;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimelinePickBanEvent {
|
||||
/** "PICK" covers MODE_PICK (and the rare trailing-bucket map PICK); "BAN" covers map and mode bans. */
|
||||
kind: "PICK" | "BAN";
|
||||
/** Consecutive events of the same kind get merged into one row, regardless of side. */
|
||||
alphaEntries: Array<{ stageId?: StageId; mode?: ModeShort }>;
|
||||
bravoEntries: Array<{ stageId?: StageId; mode?: ModeShort }>;
|
||||
}
|
||||
|
||||
export interface MatchTimelineProps {
|
||||
teams: { alpha: TimelineTeam; bravo: TimelineTeam };
|
||||
score: { alpha: number; bravo: number };
|
||||
maps: TimelineMap[];
|
||||
spChanges?: TimelineSpChanges;
|
||||
/** When true, render only the team + score header (no per-map rows or SP section). */
|
||||
compact?: boolean;
|
||||
/** When true, the match is still in progress; renders a small LIVE label under the score. */
|
||||
isOngoing?: boolean;
|
||||
/**
|
||||
* Pick/ban events keyed by the slot they precede. Length = `maps.length + 1`.
|
||||
* Bucket `i` renders above map row `i`; the trailing bucket renders after the
|
||||
* last map row (covers events made after the latest result, or the
|
||||
* pick/ban-only state with no maps reported yet).
|
||||
*/
|
||||
pickBanRowsBySlot?: TimelinePickBanEvent[][];
|
||||
}
|
||||
|
||||
export function MatchTimeline({
|
||||
teams,
|
||||
score,
|
||||
maps,
|
||||
spChanges,
|
||||
compact = false,
|
||||
isOngoing = false,
|
||||
pickBanRowsBySlot,
|
||||
}: MatchTimelineProps) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<TimelineHeader
|
||||
teams={teams}
|
||||
score={score}
|
||||
maps={maps}
|
||||
isOngoing={isOngoing}
|
||||
/>
|
||||
{compact
|
||||
? null
|
||||
: maps.map((map, i) => {
|
||||
const previousMap = maps[i - 1];
|
||||
const substitutions = previousMap
|
||||
? inferSubstitutions(previousMap.rosters, map.rosters)
|
||||
: [];
|
||||
const pickBanRows = pickBanRowsBySlot?.[i] ?? [];
|
||||
|
||||
return (
|
||||
<div key={i} className="contents">
|
||||
{pickBanRows.map((event, j) => (
|
||||
<TimelinePickBanRow key={`pb-${j}`} event={event} />
|
||||
))}
|
||||
{substitutions.map((sub, j) => (
|
||||
<TimelineSubstitutionRow key={j} substitution={sub} />
|
||||
))}
|
||||
<TimelineMapRow map={map} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!compact && pickBanRowsBySlot
|
||||
? (pickBanRowsBySlot[maps.length] ?? []).map((event, j) => (
|
||||
<TimelinePickBanRow key={`pb-trailing-${j}`} event={event} />
|
||||
))
|
||||
: null}
|
||||
{!compact && spChanges ? (
|
||||
<TimelineSpSection spChanges={spChanges} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineHeader({
|
||||
teams,
|
||||
score,
|
||||
maps,
|
||||
isOngoing,
|
||||
}: Pick<MatchTimelineProps, "teams" | "score" | "maps" | "isOngoing">) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const initialRosters = maps[0]?.rosters;
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerTeam}>
|
||||
<div
|
||||
className={clsx(styles.headerTeamName, {
|
||||
[styles.headerTeamNameLong]:
|
||||
teams.alpha.name.length > LONG_TEAM_NAME_THRESHOLD,
|
||||
})}
|
||||
>
|
||||
{teams.alpha.name}
|
||||
</div>
|
||||
{initialRosters ? (
|
||||
<div className={styles.headerAvatars}>
|
||||
{initialRosters.alpha.map((user) => (
|
||||
<Avatar key={user.id} user={user} size="xxs" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.headerScore}>
|
||||
<span className={styles.headerScoreValue}>
|
||||
{score.alpha}-{score.bravo}
|
||||
</span>
|
||||
{isOngoing ? (
|
||||
<span className={styles.headerScoreLive}>
|
||||
{t("q:match.timeline.live")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={clsx(styles.headerTeam, styles.headerTeamBravo)}>
|
||||
<div
|
||||
className={clsx(styles.headerTeamName, {
|
||||
[styles.headerTeamNameLong]:
|
||||
teams.bravo.name.length > LONG_TEAM_NAME_THRESHOLD,
|
||||
})}
|
||||
>
|
||||
{teams.bravo.name}
|
||||
</div>
|
||||
{initialRosters ? (
|
||||
<div className={styles.headerAvatars}>
|
||||
{initialRosters.bravo.map((user) => (
|
||||
<Avatar key={user.id} user={user} size="xxs" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineMapRow({ map }: { map: TimelineMap }) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
const isHydrated = useHydrated();
|
||||
const { formatTime } = useTimeFormat();
|
||||
|
||||
const alphaPoints = map.points?.[0];
|
||||
const bravoPoints = map.points?.[1];
|
||||
|
||||
return (
|
||||
<div className={styles.mapEvent}>
|
||||
<div className={styles.mapSide}>
|
||||
<SideResult
|
||||
result={map.winner === "ALPHA" ? "WIN" : "LOSS"}
|
||||
points={alphaPoints}
|
||||
weapons={map.weapons?.alpha}
|
||||
isPicked={map.pickedBy === "ALPHA"}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.mapCenter}>
|
||||
<time className={styles.mapTimestamp}>
|
||||
{isHydrated ? (
|
||||
formatTime(new Date(map.timestamp))
|
||||
) : (
|
||||
<div className="invisible">X</div>
|
||||
)}
|
||||
</time>
|
||||
<StageImage
|
||||
stageId={map.stageId}
|
||||
width={80}
|
||||
className={styles.mapStageImage}
|
||||
/>
|
||||
<div className={styles.mapLabel}>
|
||||
<ModeImage mode={map.mode} size={14} />
|
||||
<span>{shortStageName(t(`game-misc:STAGE_${map.stageId}`))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mapSide}>
|
||||
<SideResult
|
||||
result={map.winner === "BRAVO" ? "WIN" : "LOSS"}
|
||||
points={bravoPoints}
|
||||
weapons={map.weapons?.bravo}
|
||||
isPicked={map.pickedBy === "BRAVO"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SideResult({
|
||||
result,
|
||||
points,
|
||||
weapons,
|
||||
isPicked,
|
||||
}: {
|
||||
result: "WIN" | "LOSS";
|
||||
points?: number;
|
||||
weapons?: Array<MainWeaponId | null>;
|
||||
isPicked?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<div className={styles.sideResult}>
|
||||
<div className={styles.resultHeader}>
|
||||
{isPicked ? (
|
||||
<MousePointerClick
|
||||
size={14}
|
||||
className={result === "WIN" ? "text-success" : "text-error"}
|
||||
aria-label={t("q:match.timeline.picked")}
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
className={clsx(
|
||||
styles.resultLabel,
|
||||
result === "WIN" ? "text-success" : "text-error",
|
||||
)}
|
||||
>
|
||||
{result === "WIN"
|
||||
? t("q:match.timeline.win")
|
||||
: t("q:match.timeline.loss")}
|
||||
</span>
|
||||
{points === 100 ? (
|
||||
<span className={styles.resultPoints}>{t("q:match.action.ko")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{weapons ? <WeaponPool weapons={weapons} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineEventRow({
|
||||
icon,
|
||||
alphaContent,
|
||||
bravoContent,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
alphaContent: React.ReactNode;
|
||||
bravoContent: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.eventRow}>
|
||||
<div className={styles.eventAlpha}>{alphaContent}</div>
|
||||
<div className={styles.subCenter}>{icon}</div>
|
||||
<div>{bravoContent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelinePickBanRow({ event }: { event: TimelinePickBanEvent }) {
|
||||
const isPick = event.kind === "PICK";
|
||||
const icon = isPick ? (
|
||||
<MousePointerClick
|
||||
size={32}
|
||||
className={clsx(styles.eventIcon, styles.pickIcon)}
|
||||
/>
|
||||
) : (
|
||||
<X size={32} className={clsx(styles.eventIcon, styles.banIcon)} />
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineEventRow
|
||||
icon={icon}
|
||||
alphaContent={
|
||||
event.alphaEntries.length > 0 ? (
|
||||
<PickBanGroup entries={event.alphaEntries} side="ALPHA" />
|
||||
) : null
|
||||
}
|
||||
bravoContent={
|
||||
event.bravoEntries.length > 0 ? (
|
||||
<PickBanGroup entries={event.bravoEntries} side="BRAVO" />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PickBanGroup({
|
||||
entries,
|
||||
side,
|
||||
}: {
|
||||
entries: Array<{ stageId?: StageId; mode?: ModeShort }>;
|
||||
side: MatchSide;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.pickBanGroup, {
|
||||
[styles.pickBanGroupBravo]: side === "BRAVO",
|
||||
})}
|
||||
>
|
||||
{entries.map((entry, i) => (
|
||||
<PickBanEntry key={i} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickBanEntry({
|
||||
entry,
|
||||
}: {
|
||||
entry: { stageId?: StageId; mode?: ModeShort };
|
||||
}) {
|
||||
if (entry.stageId !== undefined) {
|
||||
return (
|
||||
<StageImage
|
||||
stageId={entry.stageId}
|
||||
width={56}
|
||||
className={styles.pickBanStageImage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (entry.mode !== undefined) {
|
||||
return (
|
||||
<div className={styles.pickBanModeTile}>
|
||||
<ModeImage mode={entry.mode} size={24} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function TimelineSubstitutionRow({
|
||||
substitution,
|
||||
}: {
|
||||
substitution: InferredSubstitution;
|
||||
}) {
|
||||
return (
|
||||
<TimelineEventRow
|
||||
icon={<RefreshCcw size={32} className={styles.eventIcon} />}
|
||||
alphaContent={
|
||||
substitution.side === "ALPHA" ? (
|
||||
<SubstitutionDetail substitution={substitution} />
|
||||
) : null
|
||||
}
|
||||
bravoContent={
|
||||
substitution.side === "BRAVO" ? (
|
||||
<SubstitutionDetail substitution={substitution} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SubstitutionDetail({
|
||||
substitution,
|
||||
}: {
|
||||
substitution: InferredSubstitution;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<div className={styles.subDetail}>
|
||||
<span className={styles.subLabelOut}>{t("q:match.timeline.out")}</span>
|
||||
<div className="stack horizontal items-center sm">
|
||||
<Avatar user={substitution.playerOut} size="xxxs" />
|
||||
<span className={styles.subPlayerName}>
|
||||
{substitution.playerOut.username}
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.subLabelIn}>{t("q:match.timeline.in")}</span>
|
||||
<div className="stack horizontal items-center sm">
|
||||
<Avatar user={substitution.playerIn} size="xxxs" />
|
||||
<span className={styles.subPlayerName}>
|
||||
{substitution.playerIn.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineSpSection({ spChanges }: { spChanges: TimelineSpChanges }) {
|
||||
const alphaMembersWithDiff = spChanges.alpha.members.filter(
|
||||
(m) => !m.skillDifference.calculated || m.skillDifference.spDiff !== 0,
|
||||
);
|
||||
const bravoMembersWithDiff = spChanges.bravo.members.filter(
|
||||
(m) => !m.skillDifference.calculated || m.skillDifference.spDiff !== 0,
|
||||
);
|
||||
|
||||
const maxMemberRows = Math.max(
|
||||
alphaMembersWithDiff.length,
|
||||
bravoMembersWithDiff.length,
|
||||
);
|
||||
|
||||
if (
|
||||
maxMemberRows === 0 &&
|
||||
!spChanges.alpha.skillDifference &&
|
||||
!spChanges.bravo.skillDifference
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.spSection}>
|
||||
<div className={styles.spColumn}>
|
||||
{alphaMembersWithDiff.map((m) => (
|
||||
<SpMemberDetail key={m.user.id} member={m} />
|
||||
))}
|
||||
{spChanges.alpha.skillDifference ? (
|
||||
<SpTeamDetail skillDifference={spChanges.alpha.skillDifference} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.spIcon}>
|
||||
<TrendingUp size={32} className={styles.eventIcon} />
|
||||
</div>
|
||||
<div className={styles.spColumn}>
|
||||
{bravoMembersWithDiff.map((m) => (
|
||||
<SpMemberDetail key={m.user.id} member={m} />
|
||||
))}
|
||||
{spChanges.bravo.skillDifference ? (
|
||||
<SpTeamDetail skillDifference={spChanges.bravo.skillDifference} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpMemberDetail({ member }: { member: TimelineSpMember }) {
|
||||
if (member.skillDifference.calculated) {
|
||||
const { spDiff, oldSp, newSp } = member.skillDifference;
|
||||
const isPositive = spDiff > 0;
|
||||
const arrow = isPositive ? "▲" : "▼";
|
||||
|
||||
return (
|
||||
<div className={styles.spDetail}>
|
||||
<Avatar user={member.user} size="xxs" />
|
||||
<SpDeltaTrigger
|
||||
arrow={arrow}
|
||||
isPositive={isPositive}
|
||||
value={Math.abs(spDiff)}
|
||||
oldSp={oldSp}
|
||||
newSp={newSp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
member.skillDifference.matchesCount ===
|
||||
member.skillDifference.matchesCountNeeded
|
||||
) {
|
||||
return (
|
||||
<div className={styles.spDetail}>
|
||||
<Avatar user={member.user} size="xxs" />
|
||||
<div className={styles.spDetailContent}>
|
||||
<span className={styles.spCalculatingIcon}>◆</span>
|
||||
<span>
|
||||
{member.skillDifference.newSp ? (
|
||||
<>{member.skillDifference.newSp}SP</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.spDetail}>
|
||||
<Avatar user={member.user} size="xxs" />
|
||||
<div className={styles.spDetailContent}>
|
||||
<span className={styles.spCalculatingIcon}>◆</span>
|
||||
<span>
|
||||
{member.skillDifference.matchesCount}/
|
||||
{member.skillDifference.matchesCountNeeded}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpTeamDetail({
|
||||
skillDifference,
|
||||
}: {
|
||||
skillDifference: GroupSkillDifference;
|
||||
}) {
|
||||
if (skillDifference.calculated) {
|
||||
const { oldSp, newSp } = skillDifference;
|
||||
const diff = roundToNDecimalPlaces(newSp - oldSp);
|
||||
const isPositive = diff > 0;
|
||||
const arrow = isPositive ? "▲" : "▼";
|
||||
|
||||
return (
|
||||
<div className={styles.spDetail}>
|
||||
<div className={styles.spTeamIcon}>
|
||||
<Users size={16} />
|
||||
</div>
|
||||
<SpDeltaTrigger
|
||||
arrow={arrow}
|
||||
isPositive={isPositive}
|
||||
value={Math.abs(diff)}
|
||||
oldSp={oldSp}
|
||||
newSp={newSp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skillDifference.newSp) {
|
||||
return (
|
||||
<div className={styles.spDetail}>
|
||||
<div className={styles.spTeamIcon}>
|
||||
<Users size={16} />
|
||||
</div>
|
||||
<div className={styles.spDetailContent}>
|
||||
<span className={styles.spCalculatingIcon}>◆</span>
|
||||
<span>{skillDifference.newSp}SP</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.spDetail}>
|
||||
<div className={styles.spTeamIcon}>
|
||||
<Users size={16} />
|
||||
</div>
|
||||
<div className={styles.spDetailContent}>
|
||||
<span className={styles.spCalculatingIcon}>◆</span>
|
||||
<span>
|
||||
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpDeltaTrigger({
|
||||
arrow,
|
||||
isPositive,
|
||||
value,
|
||||
oldSp,
|
||||
newSp,
|
||||
}: {
|
||||
arrow: string;
|
||||
isPositive: boolean;
|
||||
value: number;
|
||||
oldSp?: number;
|
||||
newSp?: number;
|
||||
}) {
|
||||
const arrowClass = isPositive ? "text-success" : "text-warning";
|
||||
|
||||
if (oldSp === undefined || newSp === undefined) {
|
||||
return (
|
||||
<div className={styles.spDetailContent}>
|
||||
<span className={arrowClass}>{arrow}</span>
|
||||
<span>{value}SP</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal" className={styles.spDeltaTrigger}>
|
||||
<span className={arrowClass}>{arrow}</span>
|
||||
<span>{value}SP</span>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<div className={styles.spRawPopover}>
|
||||
<span>{oldSp}SP</span>
|
||||
<ArrowRight size={16} />
|
||||
<span>{newSp}SP</span>
|
||||
</div>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
27
app/components/match-page/WeaponPool.module.css
Normal file
27
app/components/match-page/WeaponPool.module.css
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
.weaponRow {
|
||||
display: flex;
|
||||
gap: var(--s-0-5);
|
||||
background-color: var(--color-bg-higher);
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(html.light) .unknownWeapon {
|
||||
filter: drop-shadow(0 0 1px var(--color-text));
|
||||
}
|
||||
|
||||
.weaponPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.weaponPopoverRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
54
app/components/match-page/WeaponPool.tsx
Normal file
54
app/components/match-page/WeaponPool.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Button } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { SendouPopover } from "../elements/Popover";
|
||||
import { Image, WeaponImage } from "../Image";
|
||||
import styles from "./WeaponPool.module.css";
|
||||
|
||||
export function WeaponPool({
|
||||
weapons,
|
||||
size = 24,
|
||||
}: {
|
||||
weapons: Array<MainWeaponId | null>;
|
||||
size?: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<Button className={styles.weaponRow}>
|
||||
{weapons.map((weaponId, i) =>
|
||||
weaponId !== null ? (
|
||||
<WeaponImage
|
||||
key={i}
|
||||
weaponSplId={weaponId}
|
||||
variant="badge"
|
||||
size={size}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
key={i}
|
||||
className={styles.unknownWeapon}
|
||||
path="/static-assets/img/abilities/UNKNOWN"
|
||||
alt="?"
|
||||
size={size}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className={styles.weaponPopover}>
|
||||
{weapons.map((weaponId, i) =>
|
||||
weaponId !== null ? (
|
||||
<div key={i} className={styles.weaponPopoverRow}>
|
||||
<WeaponImage weaponSplId={weaponId} variant="badge" size={32} />
|
||||
<span>{t(`weapons:MAIN_${weaponId}` as any)}</span>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
108
app/components/match-page/WeaponReporter.module.css
Normal file
108
app/components/match-page/WeaponReporter.module.css
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
background-color: var(--color-bg-higher);
|
||||
border-radius: 0 0 var(--radius-box) var(--radius-box);
|
||||
padding: var(--s-4);
|
||||
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.pastRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.mapRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
|
||||
@container (max-width: 479px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.mapInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: flex-end;
|
||||
gap: var(--s-2);
|
||||
|
||||
@container (max-width: 479px) {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.mapLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.stageImage {
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.inputRow {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--s-4);
|
||||
|
||||
@container (max-width: 479px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.weaponSelectContainer {
|
||||
min-width: 200px;
|
||||
|
||||
& button {
|
||||
border: var(--border-style-high);
|
||||
}
|
||||
|
||||
@container (max-width: 479px) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.unreportedRow {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.rootCollapsed {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-higher);
|
||||
border-radius: 0 0 var(--radius-box) var(--radius-box);
|
||||
padding: var(--s-2);
|
||||
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
|
||||
}
|
||||
|
||||
.rootExpanded {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rootStandalone {
|
||||
margin-block-start: calc(-1 * var(--s-6));
|
||||
min-height: 200px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.collapseButton {
|
||||
position: absolute;
|
||||
top: var(--s-2);
|
||||
right: var(--s-3);
|
||||
|
||||
& svg {
|
||||
min-width: 22px;
|
||||
max-width: 22px;
|
||||
}
|
||||
}
|
||||
170
app/components/match-page/WeaponReporter.tsx
Normal file
170
app/components/match-page/WeaponReporter.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import clsx from "clsx";
|
||||
import { ChevronUp, Crosshair } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { abilityImageUrl, SETTINGS_PAGE } from "~/utils/urls";
|
||||
import { SendouButton } from "../elements/Button";
|
||||
import { Image, StageImage, WeaponImage } from "../Image";
|
||||
import { WeaponSelect } from "../WeaponSelect";
|
||||
import styles from "./WeaponReporter.module.css";
|
||||
|
||||
interface WeaponReporterMap {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
}
|
||||
|
||||
export interface WeaponReporterProps {
|
||||
maps: WeaponReporterMap[];
|
||||
pastReported: MainWeaponId[];
|
||||
nextMapIndex: number;
|
||||
quickSelectWeaponIds?: MainWeaponId[];
|
||||
onSubmit: (weaponSplId: MainWeaponId) => void;
|
||||
onUndo: () => void;
|
||||
isSubmitting?: boolean;
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
export function WeaponReporter({
|
||||
maps,
|
||||
pastReported,
|
||||
nextMapIndex,
|
||||
quickSelectWeaponIds,
|
||||
onSubmit,
|
||||
onUndo,
|
||||
isSubmitting,
|
||||
standalone,
|
||||
}: WeaponReporterProps) {
|
||||
const { t } = useTranslation(["q", "game-misc", "common"]);
|
||||
const user = useUser();
|
||||
const fetcher = useFetcher();
|
||||
const [isOpen, setIsOpen] = useState(
|
||||
() => user?.preferences.weaponReportDefaultOpen ?? false,
|
||||
);
|
||||
const [selectedWeapon, setSelectedWeapon] = useState<MainWeaponId | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const inputTargetMap = nextMapIndex >= 0 ? maps[nextMapIndex] : undefined;
|
||||
const unreportedCount = inputTargetMap
|
||||
? maps.length - pastReported.length - 1
|
||||
: maps.length - pastReported.length;
|
||||
|
||||
const handleToggle = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
fetcher.submit(
|
||||
{ _action: "UPDATE_WEAPON_REPORT_DEFAULT_OPEN", newValue: newOpen },
|
||||
{ method: "post", action: SETTINGS_PAGE, encType: "application/json" },
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen && !standalone) {
|
||||
return (
|
||||
<div className={styles.rootCollapsed}>
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
icon={<Crosshair size={16} />}
|
||||
onPress={() => handleToggle(true)}
|
||||
>
|
||||
{t("q:match.actions.reportWeapons")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.root, styles.rootExpanded, {
|
||||
[styles.rootStandalone]: standalone,
|
||||
})}
|
||||
>
|
||||
{standalone ? null : (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
icon={<ChevronUp size={22} />}
|
||||
onPress={() => handleToggle(false)}
|
||||
className={styles.collapseButton}
|
||||
aria-label={t("q:match.actions.reportWeapons")}
|
||||
/>
|
||||
)}
|
||||
{inputTargetMap ? (
|
||||
<div className={styles.mapRow}>
|
||||
<MapInfo map={inputTargetMap} />
|
||||
<div className={styles.inputRow}>
|
||||
<div className={styles.weaponSelectContainer}>
|
||||
<WeaponSelect
|
||||
label={`${t("q:match.weapon.yourWeapon")} #${nextMapIndex + 1}`}
|
||||
value={selectedWeapon}
|
||||
onChange={setSelectedWeapon}
|
||||
quickSelectWeaponsIds={quickSelectWeaponIds}
|
||||
/>
|
||||
</div>
|
||||
<SendouButton
|
||||
variant="primary"
|
||||
isDisabled={selectedWeapon === null || isSubmitting}
|
||||
onPress={() => {
|
||||
if (selectedWeapon === null) return;
|
||||
onSubmit(selectedWeapon);
|
||||
setSelectedWeapon(null);
|
||||
}}
|
||||
>
|
||||
{t("common:actions.submit")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pastReported.length > 0 ? (
|
||||
<div className={styles.pastRow}>
|
||||
{pastReported.map((weaponId, i) => (
|
||||
<WeaponImage
|
||||
key={i}
|
||||
weaponSplId={weaponId}
|
||||
variant="badge"
|
||||
size={24}
|
||||
/>
|
||||
))}
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
isDisabled={isSubmitting}
|
||||
onPress={onUndo}
|
||||
>
|
||||
{t("q:match.weapon.undoWeapon")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
) : null}
|
||||
{unreportedCount > 0 ? (
|
||||
<div className={styles.unreportedRow}>
|
||||
{Array.from({ length: unreportedCount }, (_, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
path={abilityImageUrl("UNKNOWN")}
|
||||
alt="?"
|
||||
size={24}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapInfo({ map }: { map: WeaponReporterMap }) {
|
||||
return (
|
||||
<div className={styles.mapInfo}>
|
||||
<StageImage
|
||||
stageId={map.stageId}
|
||||
width={100}
|
||||
className={styles.stageImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
app/components/match-page/useMatchWeaponReport.ts
Normal file
73
app/components/match-page/useMatchWeaponReport.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useFetcher } from "react-router";
|
||||
import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { WeaponReporterProps } from "./WeaponReporter";
|
||||
|
||||
/**
|
||||
* Wires the `<WeaponReporter />` component to the standard
|
||||
* `REPORT_WEAPON` / `UNDO_WEAPON_REPORT` fetcher actions and to the
|
||||
* locally persisted recently-reported weapons list.
|
||||
*
|
||||
* `maps` is the play order of maps the viewer can report a weapon for and
|
||||
* `pastReported` is the weapons the viewer has already reported, paired
|
||||
* with the `mapIndex` they were reported for.
|
||||
*/
|
||||
export function useMatchWeaponReport({
|
||||
maps,
|
||||
pastReported,
|
||||
}: {
|
||||
maps: { stageId: StageId; mode: ModeShort }[];
|
||||
pastReported: { mapIndex: number; weaponSplId: MainWeaponId }[];
|
||||
}): WeaponReporterProps {
|
||||
const weaponFetcher = useFetcher();
|
||||
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
|
||||
useRecentlyReportedWeapons();
|
||||
|
||||
const reportedMapIndexes = new Set(pastReported.map((w) => w.mapIndex));
|
||||
const nextMapIndex = (() => {
|
||||
for (let i = 0; i < maps.length; i++) {
|
||||
if (!reportedMapIndexes.has(i)) return i;
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const undoMapIndex = pastReported.reduce(
|
||||
(max, w) => Math.max(max, w.mapIndex),
|
||||
-1,
|
||||
);
|
||||
|
||||
return {
|
||||
maps,
|
||||
pastReported: [...pastReported]
|
||||
.sort((a, b) => a.mapIndex - b.mapIndex)
|
||||
.map((w) => w.weaponSplId),
|
||||
nextMapIndex,
|
||||
quickSelectWeaponIds: recentlyReportedWeapons,
|
||||
isSubmitting: weaponFetcher.state !== "idle",
|
||||
onSubmit: (weaponSplId) => {
|
||||
addRecentlyReportedWeapon(weaponSplId);
|
||||
if (nextMapIndex < 0) return;
|
||||
weaponFetcher.submit(
|
||||
{
|
||||
_action: "REPORT_WEAPON",
|
||||
weaponSplId: String(weaponSplId),
|
||||
mapIndex: String(nextMapIndex),
|
||||
},
|
||||
{ method: "post" },
|
||||
);
|
||||
},
|
||||
onUndo: () => {
|
||||
if (undoMapIndex < 0) return;
|
||||
weaponFetcher.submit(
|
||||
{
|
||||
_action: "UNDO_WEAPON_REPORT",
|
||||
mapIndex: String(undoMapIndex),
|
||||
},
|
||||
{ method: "post" },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
141
app/components/match-page/utils.test.ts
Normal file
141
app/components/match-page/utils.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, expect, it, test } from "vitest";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import { inferSubstitutions, resolveRoomPass } from "./utils";
|
||||
|
||||
function user(id: number): CommonUser {
|
||||
return {
|
||||
id,
|
||||
username: `user${id}`,
|
||||
discordId: `discord${id}`,
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("inferSubstitutions", () => {
|
||||
it("returns an empty array when rosters are unchanged", () => {
|
||||
const rosters = {
|
||||
alpha: [user(1), user(2), user(3), user(4)],
|
||||
bravo: [user(5), user(6), user(7), user(8)],
|
||||
};
|
||||
|
||||
expect(inferSubstitutions(rosters, rosters)).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects a single substitution on alpha", () => {
|
||||
const previous = {
|
||||
alpha: [user(1), user(2), user(3), user(4)],
|
||||
bravo: [user(5), user(6), user(7), user(8)],
|
||||
};
|
||||
const current = {
|
||||
alpha: [user(1), user(2), user(3), user(9)],
|
||||
bravo: previous.bravo,
|
||||
};
|
||||
|
||||
expect(inferSubstitutions(previous, current)).toEqual([
|
||||
{ side: "ALPHA", playerOut: user(4), playerIn: user(9) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects substitutions on both sides in the same map transition", () => {
|
||||
const previous = {
|
||||
alpha: [user(1), user(2)],
|
||||
bravo: [user(3), user(4)],
|
||||
};
|
||||
const current = {
|
||||
alpha: [user(1), user(10)],
|
||||
bravo: [user(11), user(4)],
|
||||
};
|
||||
|
||||
expect(inferSubstitutions(previous, current)).toEqual([
|
||||
{ side: "ALPHA", playerOut: user(2), playerIn: user(10) },
|
||||
{ side: "BRAVO", playerOut: user(3), playerIn: user(11) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("pairs multiple substitutions on the same side by roster order", () => {
|
||||
const previous = {
|
||||
alpha: [user(1), user(2), user(3), user(4)],
|
||||
bravo: [user(5), user(6)],
|
||||
};
|
||||
const current = {
|
||||
alpha: [user(1), user(10), user(3), user(11)],
|
||||
bravo: previous.bravo,
|
||||
};
|
||||
|
||||
expect(inferSubstitutions(previous, current)).toEqual([
|
||||
{ side: "ALPHA", playerOut: user(2), playerIn: user(10) },
|
||||
{ side: "ALPHA", playerOut: user(4), playerIn: user(11) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores unpaired leavers when no new player joined", () => {
|
||||
const previous = {
|
||||
alpha: [user(1), user(2), user(3), user(4)],
|
||||
bravo: [user(5), user(6)],
|
||||
};
|
||||
const current = {
|
||||
alpha: [user(1), user(2), user(3)],
|
||||
bravo: previous.bravo,
|
||||
};
|
||||
|
||||
expect(inferSubstitutions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores unpaired joiners when no player left", () => {
|
||||
const previous = {
|
||||
alpha: [user(1), user(2), user(3)],
|
||||
bravo: [user(5), user(6)],
|
||||
};
|
||||
const current = {
|
||||
alpha: [user(1), user(2), user(3), user(9)],
|
||||
bravo: previous.bravo,
|
||||
};
|
||||
|
||||
expect(inferSubstitutions(previous, current)).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats players switching sides as separate substitutions on each side", () => {
|
||||
const previous = {
|
||||
alpha: [user(1), user(2)],
|
||||
bravo: [user(3), user(4)],
|
||||
};
|
||||
const current = {
|
||||
alpha: [user(3), user(4)],
|
||||
bravo: [user(1), user(2)],
|
||||
};
|
||||
|
||||
expect(inferSubstitutions(previous, current)).toEqual([
|
||||
{ side: "ALPHA", playerOut: user(1), playerIn: user(3) },
|
||||
{ side: "ALPHA", playerOut: user(2), playerIn: user(4) },
|
||||
{ side: "BRAVO", playerOut: user(3), playerIn: user(1) },
|
||||
{ side: "BRAVO", playerOut: user(4), playerIn: user(2) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRoomPass", () => {
|
||||
test("returns a 4-digit password", () => {
|
||||
const pass = resolveRoomPass(12345);
|
||||
|
||||
expect(pass).toMatch(/^\d{4}$/);
|
||||
});
|
||||
|
||||
test("returns deterministic password for a given numeric seed", () => {
|
||||
const pass1 = resolveRoomPass(12345);
|
||||
const pass2 = resolveRoomPass(12345);
|
||||
expect(pass1).toBe(pass2);
|
||||
});
|
||||
|
||||
test("returns deterministic password for a given string seed", () => {
|
||||
const pass1 = resolveRoomPass("test-seed");
|
||||
const pass2 = resolveRoomPass("test-seed");
|
||||
expect(pass1).toBe(pass2);
|
||||
});
|
||||
|
||||
test("returns different passwords for different seeds", () => {
|
||||
const pass1 = resolveRoomPass(1);
|
||||
const pass2 = resolveRoomPass(2);
|
||||
expect(pass1).not.toBe(pass2);
|
||||
});
|
||||
});
|
||||
83
app/components/match-page/utils.ts
Normal file
83
app/components/match-page/utils.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import * as R from "remeda";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import { seededRandom } from "~/utils/random";
|
||||
|
||||
type MatchSide = "ALPHA" | "BRAVO";
|
||||
|
||||
type Rosters = {
|
||||
alpha: CommonUser[];
|
||||
bravo: CommonUser[];
|
||||
};
|
||||
|
||||
export interface InferredSubstitution {
|
||||
side: MatchSide;
|
||||
playerOut: CommonUser;
|
||||
playerIn: CommonUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the rosters of two consecutive maps and pairs up any
|
||||
* players that dropped from a side with new players that joined the same side.
|
||||
* The pairs are returned in roster order, so the first player out is paired with
|
||||
* the first new player in. When the counts don't match, unpaired players are ignored.
|
||||
*/
|
||||
export function inferSubstitutions(
|
||||
previousRosters: Rosters,
|
||||
currentRosters: Rosters,
|
||||
): InferredSubstitution[] {
|
||||
const result: InferredSubstitution[] = [];
|
||||
|
||||
for (const side of ["alpha", "bravo"] as const) {
|
||||
const prevIds = new Set(previousRosters[side].map((u) => u.id));
|
||||
const currIds = new Set(currentRosters[side].map((u) => u.id));
|
||||
|
||||
const out = previousRosters[side].filter((u) => !currIds.has(u.id));
|
||||
const inn = currentRosters[side].filter((u) => !prevIds.has(u.id));
|
||||
|
||||
for (const [playerOut, playerIn] of R.zip(out, inn)) {
|
||||
result.push({
|
||||
side: side === "alpha" ? "ALPHA" : "BRAVO",
|
||||
playerOut,
|
||||
playerIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const NUM_MAP = {
|
||||
"1": ["1", "2", "4"],
|
||||
"2": ["2", "1", "3", "5"],
|
||||
"3": ["3", "2", "6"],
|
||||
"4": ["4", "1", "5", "7"],
|
||||
"5": ["5", "2", "4", "6", "8"],
|
||||
"6": ["6", "3", "5", "9"],
|
||||
"7": ["7", "4", "8"],
|
||||
"8": ["8", "7", "5", "9", "0"],
|
||||
"9": ["9", "6", "8"],
|
||||
"0": ["0", "8"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed.
|
||||
*
|
||||
* Given the same seed, this function will always return the same password.
|
||||
*/
|
||||
export function resolveRoomPass(seed: number | string) {
|
||||
let pass = "5";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { seededShuffle } = seededRandom(`${seed}-${i}`);
|
||||
|
||||
const key = pass[i] as keyof typeof NUM_MAP;
|
||||
const opts = NUM_MAP[key];
|
||||
const next = seededShuffle(opts)[0];
|
||||
pass += next;
|
||||
}
|
||||
|
||||
// prevent 5555 since many use it as a default pass
|
||||
// making it a bit more common guess
|
||||
if (pass === "5555") return "5800";
|
||||
|
||||
return pass;
|
||||
}
|
||||
|
|
@ -5,5 +5,8 @@ export const NZAP_TEST_AVATAR = "f809176af93132c3db5f0a5019e96339"; // https://c
|
|||
export const NZAP_TEST_ID = 2;
|
||||
export const REGULAR_USER_TEST_ID = 2;
|
||||
export const ORG_ADMIN_TEST_ID = 3;
|
||||
// Matches STAFF_IDS[0] (Panda) so the seeded user is recognized as STAFF.
|
||||
export const STAFF_TEST_ID = 11329;
|
||||
export const STAFF_TEST_DISCORD_ID = "138757634500067328";
|
||||
|
||||
export const AMOUNT_OF_CALENDAR_EVENTS = 200;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import * as AssociationRepository from "~/features/associations/AssociationRepos
|
|||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { tags } from "~/features/calendar/calendar-constants";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
|
||||
import { TIMEZONES } from "~/features/lfg/lfg-constants";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
|
|
@ -22,17 +23,8 @@ import {
|
|||
} from "~/features/plus-voting/core";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import { calculateMatchSkills } from "~/features/sendouq-match/core/skills.server";
|
||||
import {
|
||||
summarizeMaps,
|
||||
summarizePlayerResults,
|
||||
} from "~/features/sendouq-match/core/summarizer.server";
|
||||
import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server";
|
||||
import { winnersArrayToWinner } from "~/features/sendouq-match/q-match-utils";
|
||||
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
|
||||
import * as SkillRepository from "~/features/sendouq-match/SkillRepository.server";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
|
||||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
|
|
@ -70,14 +62,19 @@ import {
|
|||
import { shortNanoid } from "~/utils/id";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { randomTeamName } from "~/utils/team-name";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
import { mySlugify, navIconUrl, sendouQMatchPage } from "~/utils/urls";
|
||||
import {
|
||||
getArtFilename,
|
||||
SEED_ART_URLS,
|
||||
SEED_TEAM_IMAGES,
|
||||
SEED_TOURNAMENT_IMAGES,
|
||||
} from "../../../scripts/seed-art-urls";
|
||||
import type { QWeaponPool, Tables, UserMapModePreferences } from "../tables";
|
||||
import type {
|
||||
ParsedMemento,
|
||||
QWeaponPool,
|
||||
Tables,
|
||||
UserMapModePreferences,
|
||||
} from "../tables";
|
||||
import {
|
||||
ADMIN_TEST_AVATAR,
|
||||
AMOUNT_OF_CALENDAR_EVENTS,
|
||||
|
|
@ -85,6 +82,8 @@ import {
|
|||
NZAP_TEST_DISCORD_ID,
|
||||
NZAP_TEST_ID,
|
||||
ORG_ADMIN_TEST_ID,
|
||||
STAFF_TEST_DISCORD_ID,
|
||||
STAFF_TEST_ID,
|
||||
} from "./constants";
|
||||
import placements from "./placements.json";
|
||||
|
||||
|
|
@ -173,7 +172,9 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
|||
makeAdminTournamentOrganizer,
|
||||
nzapUser,
|
||||
users,
|
||||
staffUser,
|
||||
fixAdminId,
|
||||
fixStaffUserId,
|
||||
makeArtists,
|
||||
adminUserWeaponPool,
|
||||
adminUserWidgets,
|
||||
|
|
@ -769,6 +770,26 @@ function nzapUser() {
|
|||
});
|
||||
}
|
||||
|
||||
function staffUser() {
|
||||
return UserRepository.upsert({
|
||||
discordId: STAFF_TEST_DISCORD_ID,
|
||||
discordName: "Panda",
|
||||
twitch: null,
|
||||
youtubeId: null,
|
||||
discordAvatar: null,
|
||||
discordUniqueName: null,
|
||||
});
|
||||
}
|
||||
|
||||
function fixStaffUserId() {
|
||||
sql.prepare(`delete from user where id = ${STAFF_TEST_ID}`).run();
|
||||
sql
|
||||
.prepare(
|
||||
`update "User" set "id" = ${STAFF_TEST_ID} where "discordId" = '${STAFF_TEST_DISCORD_ID}'`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
async function users() {
|
||||
const usedNames = new Set<string>();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
|
|
@ -2577,12 +2598,21 @@ async function groups(variation?: SeedVariation | null) {
|
|||
.filter((id) => id !== ADMIN_ID && id !== NZAP_TEST_ID);
|
||||
users.push(NZAP_TEST_ID);
|
||||
|
||||
let nzapGroupId = 0;
|
||||
let sendouGroupId = 0;
|
||||
const nzapGroupMemberIds: number[] = [];
|
||||
const sendouGroupMemberIds: number[] = [];
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const ownerId = users.pop()!;
|
||||
const group = await SQGroupRepository.createGroup({
|
||||
status: "ACTIVE",
|
||||
userId: users.pop()!,
|
||||
userId: ownerId,
|
||||
});
|
||||
|
||||
if (i === 0) nzapGroupMemberIds.push(ownerId);
|
||||
if (i === 1) sendouGroupMemberIds.push(ownerId);
|
||||
|
||||
const amountOfAdditionalMembers = () => {
|
||||
if (SENDOU_IN_FULL_GROUP) {
|
||||
if (i === 0) return 3;
|
||||
|
|
@ -2593,6 +2623,7 @@ async function groups(variation?: SeedVariation | null) {
|
|||
};
|
||||
|
||||
for (let j = 0; j < amountOfAdditionalMembers(); j++) {
|
||||
const memberId = users.pop()!;
|
||||
sql
|
||||
.prepare(
|
||||
/* sql */ `
|
||||
|
|
@ -2602,15 +2633,100 @@ async function groups(variation?: SeedVariation | null) {
|
|||
)
|
||||
.run({
|
||||
groupId: group.id,
|
||||
userId: users.pop()!,
|
||||
userId: memberId,
|
||||
role: "REGULAR",
|
||||
});
|
||||
|
||||
if (i === 0) nzapGroupMemberIds.push(memberId);
|
||||
if (i === 1) sendouGroupMemberIds.push(memberId);
|
||||
}
|
||||
|
||||
if (i === 0) nzapGroupId = group.id;
|
||||
if (i === 1) sendouGroupId = group.id;
|
||||
|
||||
if (i === 0 && SENDOU_IN_FULL_GROUP) {
|
||||
users.push(ADMIN_ID);
|
||||
}
|
||||
}
|
||||
|
||||
if (variation === "IN_SQ_MATCH") {
|
||||
// Sendou's side tests the matchmade cascade vote flow, NZAP's side
|
||||
// tests the trusted one-click flow.
|
||||
sql
|
||||
.prepare(
|
||||
/* sql */ `update "Group" set "matchmade" = @matchmade where "id" = @id`,
|
||||
)
|
||||
.run({ matchmade: 1, id: sendouGroupId });
|
||||
sql
|
||||
.prepare(
|
||||
/* sql */ `update "Group" set "matchmade" = @matchmade where "id" = @id`,
|
||||
)
|
||||
.run({ matchmade: 0, id: nzapGroupId });
|
||||
|
||||
const mapList = randomMapList(sendouGroupId, nzapGroupId);
|
||||
const memento = buildSeedMemento({
|
||||
mapList,
|
||||
alphaGroupId: sendouGroupId,
|
||||
bravoGroupId: nzapGroupId,
|
||||
alphaMemberIds: sendouGroupMemberIds,
|
||||
bravoMemberIds: nzapGroupMemberIds,
|
||||
});
|
||||
|
||||
const createdMatch = await SQMatchRepository.create({
|
||||
alphaGroupId: sendouGroupId,
|
||||
bravoGroupId: nzapGroupId,
|
||||
mapList,
|
||||
memento,
|
||||
});
|
||||
|
||||
const guaranteedWeaponPoolUserIds = [
|
||||
sendouGroupMemberIds[1],
|
||||
sendouGroupMemberIds[2],
|
||||
nzapGroupMemberIds[1],
|
||||
nzapGroupMemberIds[2],
|
||||
].filter((id): id is number => typeof id === "number");
|
||||
for (const userId of guaranteedWeaponPoolUserIds) {
|
||||
const weapons: QWeaponPool[] = [
|
||||
{ weaponSplId: 0, isFavorite: 1 },
|
||||
{ weaponSplId: 2000, isFavorite: 0 },
|
||||
{ weaponSplId: 4000, isFavorite: 0 },
|
||||
];
|
||||
await db
|
||||
.updateTable("User")
|
||||
.set({ qWeaponPool: JSON.stringify(weapons) })
|
||||
.where("User.id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
if (createdMatch.chatCode) {
|
||||
await ChatSystemMessage.setMetadata({
|
||||
chatCode: createdMatch.chatCode,
|
||||
header: `Match #${createdMatch.id}`,
|
||||
subtitle: "SendouQ",
|
||||
url: sendouQMatchPage(createdMatch.id),
|
||||
imageUrl: `${navIconUrl("sendouq")}.avif`,
|
||||
participantUserIds: [...sendouGroupMemberIds, ...nzapGroupMemberIds],
|
||||
expiresAfter: { hours: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
const thirtyMinutesAgo = dateToDatabaseTimestamp(
|
||||
sub(new Date(), { minutes: 30 }),
|
||||
);
|
||||
sql
|
||||
.prepare(
|
||||
/* sql */ `
|
||||
insert into "RoomLink" ("userId", "url", "createdAt", "refreshedAt")
|
||||
values (@userId, @url, @createdAt, @refreshedAt)
|
||||
`,
|
||||
)
|
||||
.run({
|
||||
userId: ADMIN_ID,
|
||||
url: "https://example.com//private_battle/seed_room_123",
|
||||
createdAt: thirtyMinutesAgo,
|
||||
refreshedAt: thirtyMinutesAgo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function teamMapPrefsGroups() {
|
||||
|
|
@ -2702,6 +2818,108 @@ const randomMapList = (
|
|||
return mapList;
|
||||
};
|
||||
|
||||
function buildSeedMemento({
|
||||
mapList,
|
||||
alphaGroupId,
|
||||
bravoGroupId,
|
||||
alphaMemberIds,
|
||||
bravoMemberIds,
|
||||
}: {
|
||||
mapList: TournamentMapListMap[];
|
||||
alphaGroupId: number;
|
||||
bravoGroupId: number;
|
||||
alphaMemberIds: number[];
|
||||
bravoMemberIds: number[];
|
||||
}): ParsedMemento {
|
||||
const userPools = new Map<number, Map<ModeShort, Set<StageId>>>();
|
||||
|
||||
const addVote = (userId: number, mode: ModeShort, stageId: StageId) => {
|
||||
let modes = userPools.get(userId);
|
||||
if (!modes) {
|
||||
modes = new Map();
|
||||
userPools.set(userId, modes);
|
||||
}
|
||||
let stages = modes.get(mode);
|
||||
if (!stages) {
|
||||
stages = new Set();
|
||||
modes.set(mode, stages);
|
||||
}
|
||||
stages.add(stageId);
|
||||
};
|
||||
|
||||
for (const map of mapList) {
|
||||
const candidates: number[] =
|
||||
map.source === "BOTH"
|
||||
? [...alphaMemberIds, ...bravoMemberIds]
|
||||
: map.source === alphaGroupId
|
||||
? alphaMemberIds
|
||||
: map.source === bravoGroupId
|
||||
? bravoMemberIds
|
||||
: [];
|
||||
|
||||
if (candidates.length === 0) continue;
|
||||
|
||||
const voterCount = faker.number.int({ min: 1, max: candidates.length });
|
||||
const voters = faker.helpers.arrayElements(candidates, voterCount);
|
||||
|
||||
for (const voterId of voters) {
|
||||
addVote(voterId, map.mode, map.stageId);
|
||||
}
|
||||
}
|
||||
|
||||
const pools: ParsedMemento["pools"] = Array.from(userPools.entries()).map(
|
||||
([userId, modes]) => ({
|
||||
userId,
|
||||
pool: Array.from(modes.entries()).map(([mode, stages]) => ({
|
||||
mode,
|
||||
stages: Array.from(stages),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
const tierNames = [
|
||||
"LEVIATHAN",
|
||||
"DIAMOND",
|
||||
"PLATINUM",
|
||||
"GOLD",
|
||||
"SILVER",
|
||||
"BRONZE",
|
||||
"IRON",
|
||||
] as const;
|
||||
|
||||
const users: ParsedMemento["users"] = {};
|
||||
for (const userId of [...alphaMemberIds, ...bravoMemberIds]) {
|
||||
const tierName = faker.helpers.arrayElement(tierNames);
|
||||
users[userId] = {
|
||||
skill: {
|
||||
ordinal: faker.number.float({ min: 1000, max: 3000 }),
|
||||
tier: {
|
||||
name: tierName,
|
||||
isPlus: faker.datatype.boolean(),
|
||||
},
|
||||
approximate: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const groups: ParsedMemento["groups"] = {
|
||||
[alphaGroupId]: {
|
||||
tier: {
|
||||
name: faker.helpers.arrayElement(tierNames),
|
||||
isPlus: faker.datatype.boolean(),
|
||||
},
|
||||
},
|
||||
[bravoGroupId]: {
|
||||
tier: {
|
||||
name: faker.helpers.arrayElement(tierNames),
|
||||
isPlus: faker.datatype.boolean(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { users, groups, pools };
|
||||
}
|
||||
|
||||
const MATCHES_COUNT = 500;
|
||||
|
||||
const AMOUNT_OF_USERS_WITH_SKILLS = 100;
|
||||
|
|
@ -2805,57 +3023,25 @@ async function playedMatches() {
|
|||
["ALPHA", "BRAVO", "BRAVO", "ALPHA", "ALPHA", "ALPHA"],
|
||||
["ALPHA", "BRAVO", "ALPHA", "BRAVO", "BRAVO", "BRAVO"],
|
||||
]) as ("ALPHA" | "BRAVO")[];
|
||||
const winner = winnersArrayToWinner(winners);
|
||||
const finishedMatch = SendouQ.mapMatch(
|
||||
(await SQMatchRepository.findById(match.id))!,
|
||||
);
|
||||
|
||||
const { newSkills, differences } = calculateMatchSkills({
|
||||
groupMatchId: match.id,
|
||||
winner: winner === "ALPHA" ? groupAlphaMembers : groupBravoMembers,
|
||||
loser: winner === "ALPHA" ? groupBravoMembers : groupAlphaMembers,
|
||||
loserGroupId: winner === "ALPHA" ? groupBravo : groupAlpha,
|
||||
winnerGroupId: winner === "ALPHA" ? groupAlpha : groupBravo,
|
||||
});
|
||||
|
||||
const members = [
|
||||
...finishedMatch.groupAlpha.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.alphaGroupId,
|
||||
})),
|
||||
...finishedMatch.groupBravo.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.bravoGroupId,
|
||||
})),
|
||||
];
|
||||
await SQMatchRepository.updateScore({
|
||||
matchId: match.id,
|
||||
reportedByUserId:
|
||||
faker.number.float(1) > 0.5
|
||||
? groupAlphaMembers[0]
|
||||
: groupBravoMembers[0],
|
||||
winners,
|
||||
});
|
||||
await SkillRepository.createMatchSkills({
|
||||
skills: newSkills,
|
||||
differences,
|
||||
groupMatchId: match.id,
|
||||
oldMatchMemento: { users: {}, groups: {}, pools: [] },
|
||||
});
|
||||
await SQGroupRepository.setAsInactive(groupAlpha);
|
||||
await SQGroupRepository.setAsInactive(groupBravo);
|
||||
await PlayerStatRepository.upsertMapResults(
|
||||
summarizeMaps({ match: finishedMatch, members, winners }),
|
||||
);
|
||||
await PlayerStatRepository.upsertPlayerResults(
|
||||
summarizePlayerResults({ match: finishedMatch, members, winners }),
|
||||
);
|
||||
const reporterUserId =
|
||||
faker.number.float(1) > 0.5 ? groupAlphaMembers[0] : groupBravoMembers[0];
|
||||
for (const [mapIndex, winner] of winners.entries()) {
|
||||
await SQMatchRepository.reportMapWinner({
|
||||
matchId: match.id,
|
||||
winnerId: winner === "ALPHA" ? groupAlpha : groupBravo,
|
||||
reportedByUserId: reporterUserId,
|
||||
reportedCount: mapIndex,
|
||||
isStaffReport: true,
|
||||
});
|
||||
}
|
||||
|
||||
// -> add weapons for 90% of matches
|
||||
if (faker.number.float(1) > 0.9) continue;
|
||||
const finishedMatch = (await SQMatchRepository.findById(match.id))!;
|
||||
const users = [...groupAlphaMembers, ...groupBravoMembers];
|
||||
const mapsWithUsers = users.flatMap((u) =>
|
||||
finishedMatch.mapList.map((m) => ({ map: m, user: u })),
|
||||
finishedMatch.mapList.map((_, mapIndex) => ({ mapIndex, user: u })),
|
||||
);
|
||||
|
||||
await ReportedWeaponRepository.createMany(
|
||||
|
|
@ -2873,7 +3059,8 @@ async function playedMatches() {
|
|||
};
|
||||
|
||||
return {
|
||||
groupMatchMapId: mu.map.id,
|
||||
groupMatchId: match.id,
|
||||
mapIndex: mu.mapIndex,
|
||||
userId: mu.user,
|
||||
weaponSplId: weapon(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -237,6 +237,8 @@ export interface Group {
|
|||
id: GeneratedAlways<number>;
|
||||
inviteCode: string;
|
||||
latestActionAt: Generated<number>;
|
||||
/** If truthy, group was at least partly made in the matchmaking UI (/q/looking) */
|
||||
matchmade: Generated<DBBoolean>;
|
||||
status: "PREPARING" | "ACTIVE" | "INACTIVE";
|
||||
teamId: number | null;
|
||||
}
|
||||
|
|
@ -259,6 +261,8 @@ export type UserSkillDifference =
|
|||
| {
|
||||
calculated: true;
|
||||
spDiff: number;
|
||||
oldSp?: number;
|
||||
newSp?: number;
|
||||
}
|
||||
| CalculatingSkill;
|
||||
export type GroupSkillDifference =
|
||||
|
|
@ -301,11 +305,21 @@ export interface GroupMatch {
|
|||
alphaGroupId: number;
|
||||
bravoGroupId: number;
|
||||
chatCode: string | null;
|
||||
confirmedAt: number | null;
|
||||
confirmedByUserId: number | null;
|
||||
createdAt: Generated<number>;
|
||||
id: GeneratedAlways<number>;
|
||||
memento: JSONColumnTypeNullable<ParsedMemento>;
|
||||
reportedAt: number | null;
|
||||
reportedByUserId: number | null;
|
||||
cancelRequestedByUserId: number | null;
|
||||
cancelAcceptedByUserId: number | null;
|
||||
}
|
||||
|
||||
export interface GroupMatchContinueVote {
|
||||
id: GeneratedAlways<number>;
|
||||
groupId: number;
|
||||
userId: number;
|
||||
isContinuing: DBBoolean;
|
||||
votedAt: Generated<number>;
|
||||
}
|
||||
|
||||
export interface GroupMatchMap {
|
||||
|
|
@ -313,6 +327,8 @@ export interface GroupMatchMap {
|
|||
index: number;
|
||||
matchId: number;
|
||||
mode: ModeShort;
|
||||
reportedAt: number | null;
|
||||
reportedByUserId: number | null;
|
||||
source: string;
|
||||
stageId: StageId;
|
||||
winnerGroupId: number | null;
|
||||
|
|
@ -438,7 +454,9 @@ export interface PlusVotingResult {
|
|||
}
|
||||
|
||||
export interface ReportedWeapon {
|
||||
groupMatchMapId: number | null;
|
||||
groupMatchId: number | null;
|
||||
tournamentMatchId: number | null;
|
||||
mapIndex: number;
|
||||
userId: number;
|
||||
weaponSplId: MainWeaponId;
|
||||
}
|
||||
|
|
@ -986,10 +1004,20 @@ export interface UserPreferences {
|
|||
* "12h" = 12 hour format (e.g. 2:00 PM)
|
||||
* */
|
||||
clockFormat?: "24h" | "12h" | "auto";
|
||||
/**
|
||||
* What numeric date format the user prefers?
|
||||
*
|
||||
* "auto" = use the format the active language defaults to (default value)
|
||||
* "MDY" = month/day/year (e.g. 4/27/2026)
|
||||
* "DMY" = day/month/year (e.g. 27/04/2026)
|
||||
* "YMD" = ISO year-month-day (e.g. 2026-04-27)
|
||||
* */
|
||||
dateFormat?: "auto" | "MDY" | "DMY" | "YMD";
|
||||
/** Is the new widget based user page enabled? (Supporter early preview) */
|
||||
newProfileEnabled?: boolean;
|
||||
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */
|
||||
spoilerFreeMode?: boolean;
|
||||
weaponReportDefaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const;
|
||||
|
|
@ -1050,6 +1078,8 @@ export interface User {
|
|||
qWeaponPool: JSONColumnTypeNullable<QWeaponPool[]>;
|
||||
plusSkippedForSeasonNth: number | null;
|
||||
noScreen: Generated<DBBoolean>;
|
||||
/** User doesn't have access to SplatNet 3 to join rooms made by others */
|
||||
noSplatnet: Generated<DBBoolean>;
|
||||
buildSorting: JSONColumnTypeNullable<BuildSort[]>;
|
||||
preferences: JSONColumnTypeNullable<UserPreferences>;
|
||||
/** User creation date. Can be null because we did not always save this. */
|
||||
|
|
@ -1297,6 +1327,13 @@ export interface NotificationUserSubscription {
|
|||
subscription: JSONColumnType<NotificationSubscription>;
|
||||
}
|
||||
|
||||
export interface RoomLink {
|
||||
userId: number;
|
||||
url: string;
|
||||
createdAt: Generated<number>;
|
||||
refreshedAt: Generated<number>;
|
||||
}
|
||||
|
||||
export const SPLATOON_ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const;
|
||||
export type SplatoonRotationType = (typeof SPLATOON_ROTATION_TYPES)[number];
|
||||
|
||||
|
|
@ -1341,6 +1378,7 @@ export interface DB {
|
|||
Group: Group;
|
||||
GroupLike: GroupLike;
|
||||
GroupMatch: GroupMatch;
|
||||
GroupMatchContinueVote: GroupMatchContinueVote;
|
||||
GroupMatchMap: GroupMatchMap;
|
||||
GroupMember: GroupMember;
|
||||
PrivateUserNote: PrivateUserNote;
|
||||
|
|
@ -1353,6 +1391,7 @@ export interface DB {
|
|||
PlusTier: PlusTier;
|
||||
PlusVote: PlusVote;
|
||||
PlusVotingResult: PlusVotingResult;
|
||||
RoomLink: RoomLink;
|
||||
ReportedWeapon: ReportedWeapon;
|
||||
Skill: Skill;
|
||||
SkillTeamUser: SkillTeamUser;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export default async function handleRequest(
|
|||
lng, // The locale we detected above
|
||||
ns, // The namespaces the routes about to render wants to use
|
||||
resources,
|
||||
showSupportNotice: false,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
errorToast(`Migration failed. Reason: ${errorMessage}`);
|
||||
}
|
||||
|
||||
await refreshBannedCache();
|
||||
|
||||
message = "Account migrated";
|
||||
break;
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -9,4 +9,5 @@ export const SEED_VARIATIONS = [
|
|||
"TEAM_MAP_PREFS",
|
||||
"FINALIZED_BRACKET",
|
||||
"AB_RR",
|
||||
"IN_SQ_MATCH",
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { z } from "zod";
|
|||
import { db } from "~/db/sql";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { resolveMapList } from "~/features/tournament-bracket/core/mapList.server";
|
||||
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { resolveMapList } from "~/features/tournament-match/core/mapList.server";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
|
||||
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import type { GetTournamentPlayersResponse } from "../schema";
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
|
|||
<SendouDialog
|
||||
heading={formatDate(databaseTimestampToDate(art.createdAt), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})}
|
||||
onClose={close}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export default function NewArtPage() {
|
|||
const submitButtonDisabled = () => {
|
||||
if (fetcher.state !== "idle") return true;
|
||||
|
||||
return !img && !data.art;
|
||||
return (!img || !smallImg) && !data.art;
|
||||
};
|
||||
|
||||
if (!isArtist) {
|
||||
|
|
@ -121,7 +121,6 @@ function ImageUpload({
|
|||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
name="img"
|
||||
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||
onChange={(e) => {
|
||||
const uploadedFile = e.target.files?.[0];
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export const meta: MetaFunction = (args) => {
|
|||
};
|
||||
|
||||
export default function ArtPage() {
|
||||
const { t } = useTranslation(["art", "common"]);
|
||||
const { t } = useTranslation(["art", "common", "forms"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const switchId = React.useId();
|
||||
|
|
@ -100,7 +100,7 @@ export default function ArtPage() {
|
|||
id={switchId}
|
||||
/>
|
||||
<Label htmlFor={switchId} className="m-auto-0">
|
||||
{t("art:openCommissionsOnly")}
|
||||
{t("forms:labels.profileCommissionsOpen")}
|
||||
</Label>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -23,11 +23,6 @@ export function articleBySlug(slug: string) {
|
|||
return {
|
||||
content,
|
||||
date,
|
||||
dateString: date.toLocaleDateString("en-US", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
authors: normalizeAuthors(restParsed.author),
|
||||
title: restParsed.title,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export async function mostRecentArticles(count: number) {
|
|||
const articles: Array<
|
||||
Omit<NonNullable<ReturnType<typeof articleBySlug>>, "content"> & {
|
||||
slug: string;
|
||||
dateString: string;
|
||||
}
|
||||
> = [];
|
||||
for (const file of files) {
|
||||
|
|
@ -25,11 +24,6 @@ export async function mostRecentArticles(count: number) {
|
|||
articles.push({
|
||||
date,
|
||||
slug: file.replace(".md", ""),
|
||||
dateString: date.toLocaleDateString("en-US", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
authors: normalizeAuthors(restParsed.author),
|
||||
title: restParsed.title,
|
||||
});
|
||||
|
|
@ -37,6 +31,5 @@ export async function mostRecentArticles(count: number) {
|
|||
|
||||
return articles
|
||||
.sort((a, b) => b.date.getTime() - a.date.getTime())
|
||||
.slice(0, count)
|
||||
.map(({ date: _date, ...rest }) => rest);
|
||||
.slice(0, count);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { MetaFunction } from "react-router";
|
|||
import { Link, useLoaderData } from "react-router";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Markdown } from "~/components/Markdown";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
|
|
@ -52,12 +53,20 @@ export const meta: MetaFunction = (args) => {
|
|||
|
||||
export default function ArticlePage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { formatDate } = useTimeFormat();
|
||||
return (
|
||||
<Main>
|
||||
<article className="article">
|
||||
<h1>{data.title}</h1>
|
||||
<div className="text-sm text-lighter">
|
||||
by <Author /> • <time>{data.dateString}</time>
|
||||
by <Author /> •{" "}
|
||||
<time>
|
||||
{formatDate(new Date(data.date), {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
<Markdown>
|
||||
{contentWithoutLeadingTitle(data.content, data.title)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
|||
import type { MetaFunction } from "react-router";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { Main } from "~/components/Main";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { ARTICLES_MAIN_PAGE, articlePage, navIconUrl } from "~/utils/urls";
|
||||
import { metaTags } from "../../../utils/remix";
|
||||
|
|
@ -30,6 +31,7 @@ export const meta: MetaFunction = (args) => {
|
|||
|
||||
export default function ArticlesMainPage() {
|
||||
const { t, i18n } = useTranslation(["common"]);
|
||||
const { formatDate } = useTimeFormat();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
|
|
@ -46,7 +48,14 @@ export default function ArticlesMainPage() {
|
|||
style: "short",
|
||||
}).format(article.authors.map((a) => a.name)),
|
||||
})}{" "}
|
||||
• <time>{article.dateString}</time>
|
||||
•{" "}
|
||||
<time>
|
||||
{formatDate(new Date(article.date), {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -767,6 +767,10 @@
|
|||
"displayName": "Minus One Open",
|
||||
"authorDiscordId": "569271521776762896"
|
||||
},
|
||||
"moneycat": {
|
||||
"displayName": "Cats Have 9 Lives",
|
||||
"authorDiscordId": "781999824765452310"
|
||||
},
|
||||
"mrgrizz": {
|
||||
"displayName": "Salmon Workers",
|
||||
"authorDiscordId": "345996252032401408"
|
||||
|
|
@ -1227,6 +1231,10 @@
|
|||
"displayName": "Squidforce Cup",
|
||||
"authorDiscordId": "1320944066002681876"
|
||||
},
|
||||
"sssge": {
|
||||
"displayName": "Solo Squid Showdown: Golden Edition",
|
||||
"authorDiscordId": "824681527815438337"
|
||||
},
|
||||
"stellatedzone": {
|
||||
"displayName": "Stellated Zone",
|
||||
"authorDiscordId": "309327923129745409"
|
||||
|
|
@ -1467,6 +1475,14 @@
|
|||
"displayName": "Weapon Lockdown Special Edition",
|
||||
"authorDiscordId": "338806780446638082"
|
||||
},
|
||||
"wellstringcustom": {
|
||||
"displayName": "Wellstring Propoganda: Low Cut",
|
||||
"authorDiscordId": "338806780446638082"
|
||||
},
|
||||
"wellstringregular": {
|
||||
"displayName": "Wellstring Propoganda: Top Cut",
|
||||
"authorDiscordId": "338806780446638082"
|
||||
},
|
||||
"whitecat": {
|
||||
"displayName": "White Cat Achievement",
|
||||
"authorDiscordId": "631246535560265749"
|
||||
|
|
@ -1475,6 +1491,10 @@
|
|||
"displayName": "Wi Wi Wi Cat",
|
||||
"authorDiscordId": "530722502603833346"
|
||||
},
|
||||
"wings": {
|
||||
"displayName": "Wings Up!",
|
||||
"authorDiscordId": "752582395076673577"
|
||||
},
|
||||
"wiper": {
|
||||
"displayName": "Squeaky Clean Scrap",
|
||||
"authorDiscordId": "528851510222782474"
|
||||
|
|
|
|||
|
|
@ -6,14 +6,19 @@ import {
|
|||
} from "~/features/auth/core/authenticator.server";
|
||||
import { authSessionStorage } from "~/features/auth/core/session.server";
|
||||
import type { Nullish } from "~/utils/types";
|
||||
import { userIsBanned } from "../core/banned.server";
|
||||
import { refreshBannedCache, userIsBanned } from "../core/banned.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const userId = await getUserIdEvenIfBanned(request);
|
||||
|
||||
if (!userId || !userIsBanned(userId)) return redirect("/");
|
||||
|
||||
const bannedStatus = (await AdminRepository.allBannedUsers()).get(userId)!;
|
||||
const bannedStatus = (await AdminRepository.allBannedUsers()).get(userId);
|
||||
|
||||
if (!bannedStatus) {
|
||||
await refreshBannedCache();
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
return {
|
||||
banned: bannedStatus.banned,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default function SuspendedPage() {
|
|||
<div suppressHydrationWarning>
|
||||
Ends:{" "}
|
||||
{formatDateTime(ends, {
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
mainWeaponIds,
|
||||
nonBombSubWeaponIds,
|
||||
nonDamagingSpecialWeaponIds,
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
weaponCategories,
|
||||
weaponIdToBaseWeaponId,
|
||||
|
|
@ -216,7 +217,10 @@ export function validatedAnyWeaponFromSearchParams(
|
|||
if (rawWeapon?.startsWith("SPECIAL_")) {
|
||||
const id = Number(rawWeapon.replace("SPECIAL_", ""));
|
||||
|
||||
if (nonDamagingSpecialWeaponIds.includes(id)) {
|
||||
if (
|
||||
!specialWeaponIds.includes(id as any) ||
|
||||
nonDamagingSpecialWeaponIds.includes(id)
|
||||
) {
|
||||
return DEFAULT_ANY_WEAPON;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -257,9 +257,9 @@ export async function ownerIdById(buildId: number) {
|
|||
.selectFrom("Build")
|
||||
.select("ownerId")
|
||||
.where("id", "=", buildId)
|
||||
.executeTakeFirstOrThrow();
|
||||
.executeTakeFirst();
|
||||
|
||||
return result.ownerId;
|
||||
return result?.ownerId ?? null;
|
||||
}
|
||||
|
||||
export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) {
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ function DateFilter({
|
|||
{patch} (
|
||||
{formatDate(date, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
} from "~/modules/in-game-lists/types";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import type { BuildFiltersFromSearchParams } from "../builds-schemas.server";
|
||||
import type { BuildFiltersFromSearchParams } from "../builds-schemas";
|
||||
import type {
|
||||
AbilityBuildFilter,
|
||||
DateBuildFilter,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
import {
|
||||
buildFiltersSearchParams,
|
||||
buildsLimitSearchParam,
|
||||
} from "../builds-schemas.server";
|
||||
} from "../builds-schemas";
|
||||
import { filterBuilds } from "../core/filter.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { BuildCard } from "~/components/BuildCard";
|
|||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
|
||||
import { Main } from "~/components/Main";
|
||||
import { safeJSONParse } from "~/utils/json";
|
||||
import { isRevalidation, metaTags, type SerializeFrom } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
|
|
@ -37,7 +36,10 @@ import {
|
|||
MAX_BUILD_FILTERS,
|
||||
PATCHES,
|
||||
} from "../builds-constants";
|
||||
import type { BuildFiltersFromSearchParams } from "../builds-schemas.server";
|
||||
import {
|
||||
type BuildFiltersFromSearchParams,
|
||||
buildFiltersSearchParams,
|
||||
} from "../builds-schemas";
|
||||
import type { AbilityBuildFilter, BuildFilter } from "../builds-types";
|
||||
import { FilterSection } from "../components/FilterSection";
|
||||
|
||||
|
|
@ -94,7 +96,10 @@ function parseFiltersFromSearchParams(
|
|||
const raw = searchParams.get(FILTER_SEARCH_PARAM_KEY);
|
||||
if (!raw) return [];
|
||||
|
||||
return safeJSONParse<BuildFilter[]>(raw, []);
|
||||
const parsed = buildFiltersSearchParams.safeParse(raw);
|
||||
if (!parsed.success || !parsed.data) return [];
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function extractMeaningfulFilters(
|
||||
|
|
|
|||
|
|
@ -28,12 +28,6 @@ export const tags = {
|
|||
QUALIFIER: {
|
||||
color: "#FFC0CB",
|
||||
},
|
||||
SZ: {
|
||||
color: "#F44336",
|
||||
},
|
||||
TW: {
|
||||
color: "#D50000",
|
||||
},
|
||||
ONES: {
|
||||
color: "#FAEC25",
|
||||
},
|
||||
|
|
@ -97,12 +91,7 @@ export type RegClosesAtOption = (typeof REG_CLOSES_AT_OPTIONS)[number];
|
|||
export const DAYS_SHOWN_AT_A_TIME = 4;
|
||||
|
||||
/** Tags not shown on the tournament cards */
|
||||
export const EXCLUDED_TAGS: Array<CalendarEventTag> = [
|
||||
"CARDS",
|
||||
"SR",
|
||||
"SZ",
|
||||
"TW",
|
||||
];
|
||||
export const EXCLUDED_TAGS: Array<CalendarEventTag> = ["CARDS", "SR"];
|
||||
|
||||
export const CALENDAR_EVENT_RESULT = {
|
||||
MAX_PARTICIPANTS_COUNT: 1000,
|
||||
|
|
|
|||
|
|
@ -63,8 +63,6 @@ const TAGS_TO_OMIT: CalendarEventTag[] = [
|
|||
"SR",
|
||||
"S1",
|
||||
"S2",
|
||||
"SZ",
|
||||
"TW",
|
||||
"ONES",
|
||||
"DUOS",
|
||||
"TRIOS",
|
||||
|
|
|
|||
|
|
@ -46,13 +46,13 @@ export interface ShowcaseCalendarEvent extends CommonEvent {
|
|||
hidden: boolean;
|
||||
isFinalized: boolean;
|
||||
minMembersPerTeam: number;
|
||||
firstPlacer: {
|
||||
firstPlacers: Array<{
|
||||
teamName: string;
|
||||
logoUrl: string | null;
|
||||
members: (CommonUser & { country: Tables["User"]["country"] })[];
|
||||
notShownMembersCount: number;
|
||||
div: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
hasVods?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@
|
|||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
:global(html.light) .tag {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tagDeleteButton {
|
||||
margin-left: auto;
|
||||
|
||||
|
|
@ -41,3 +45,7 @@
|
|||
margin-inline: var(--s-1) 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.light) .tagDeleteButton > svg {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function TournamentCard({
|
|||
}
|
||||
|
||||
return formatDateTimeSmartMinutes(date, {
|
||||
month: "short",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
weekday: "short",
|
||||
|
|
@ -57,7 +57,8 @@ export function TournamentCard({
|
|||
return (
|
||||
<div
|
||||
className={clsx(className, styles.container, {
|
||||
[styles.containerTall]: isShowcase && tournament.firstPlacer,
|
||||
[styles.containerTall]:
|
||||
isShowcase && tournament.firstPlacers.length > 0,
|
||||
})}
|
||||
data-testid="tournament-card"
|
||||
>
|
||||
|
|
@ -117,15 +118,17 @@ export function TournamentCard({
|
|||
<Tags tags={tournament.tags} small centered />
|
||||
</div>
|
||||
) : null}
|
||||
{isShowcase && tournament.firstPlacer ? (
|
||||
{isShowcase && tournament.firstPlacers.length > 0 ? (
|
||||
<TournamentFirstPlacers
|
||||
firstPlacer={tournament.firstPlacer}
|
||||
firstPlacers={tournament.firstPlacers}
|
||||
censored={isCensored(tournament.id)}
|
||||
/>
|
||||
) : null}
|
||||
</Link>
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
{isShowcase && tournament.firstPlacer && isCensored(tournament.id) ? (
|
||||
{isShowcase &&
|
||||
tournament.firstPlacers.length > 0 &&
|
||||
isCensored(tournament.id) ? (
|
||||
<SpoilerRevealPill onReveal={() => reveal(tournament.id)} />
|
||||
) : null}
|
||||
{isShowcase && "hasVods" in tournament && tournament.hasVods ? (
|
||||
|
|
@ -157,20 +160,52 @@ export function TournamentCard({
|
|||
}
|
||||
|
||||
function TournamentFirstPlacers({
|
||||
firstPlacer,
|
||||
firstPlacers,
|
||||
censored,
|
||||
}: {
|
||||
firstPlacer: NonNullable<ShowcaseCalendarEvent["firstPlacer"]>;
|
||||
firstPlacers: ShowcaseCalendarEvent["firstPlacers"];
|
||||
censored: boolean;
|
||||
}) {
|
||||
if (firstPlacers.length > 1) {
|
||||
return (
|
||||
<div className={styles.firstPlacers}>
|
||||
<div className="stack md items-start">
|
||||
{firstPlacers.map((placer) => (
|
||||
<TournamentFirstPlacerTeamNameOnly
|
||||
key={placer.div ?? placer.teamName}
|
||||
placer={placer}
|
||||
censored={censored}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const placer = firstPlacers[0];
|
||||
|
||||
return (
|
||||
<div className={styles.firstPlacers}>
|
||||
<TournamentFirstPlacerWithMembers placer={placer} censored={censored} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TournamentFirstPlacerWithMembers({
|
||||
placer,
|
||||
censored,
|
||||
}: {
|
||||
placer: ShowcaseCalendarEvent["firstPlacers"][number];
|
||||
censored: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["front"]);
|
||||
|
||||
return (
|
||||
<div className={styles.firstPlacers}>
|
||||
<>
|
||||
<div className="stack xs horizontal items-center text-xs">
|
||||
{!censored && firstPlacer.logoUrl ? (
|
||||
{!censored && placer.logoUrl ? (
|
||||
<img
|
||||
src={firstPlacer.logoUrl}
|
||||
src={placer.logoUrl}
|
||||
alt=""
|
||||
width={24}
|
||||
className="rounded-full"
|
||||
|
|
@ -178,16 +213,16 @@ function TournamentFirstPlacers({
|
|||
) : null}{" "}
|
||||
<div className="stack items-start">
|
||||
<span className={styles.firstPlacersTeamName}>
|
||||
{censored ? "???" : firstPlacer.teamName}
|
||||
{censored ? "???" : placer.teamName}
|
||||
</span>
|
||||
<div className="text-xxxs text-lighter font-bold text-uppercase">
|
||||
{t("front:showcase.card.winner")}
|
||||
{firstPlacer.div ? ` (${firstPlacer.div})` : null}
|
||||
{placer.div ? ` (${placer.div})` : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xxs stack items-start mt-1">
|
||||
{firstPlacer.members.map((member) => (
|
||||
{placer.members.map((member) => (
|
||||
<div key={member.id} className="stack horizontal xs items-center">
|
||||
{!censored && member.country ? (
|
||||
<Flag tiny countryCode={member.country} />
|
||||
|
|
@ -195,12 +230,34 @@ function TournamentFirstPlacers({
|
|||
{censored ? "???" : member.username}{" "}
|
||||
</div>
|
||||
))}
|
||||
{!censored && firstPlacer.notShownMembersCount > 0 ? (
|
||||
{!censored && placer.notShownMembersCount > 0 ? (
|
||||
<div className="font-bold text-lighter">
|
||||
+{firstPlacer.notShownMembersCount}
|
||||
+{placer.notShownMembersCount}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TournamentFirstPlacerTeamNameOnly({
|
||||
placer,
|
||||
censored,
|
||||
}: {
|
||||
placer: ShowcaseCalendarEvent["firstPlacers"][number];
|
||||
censored: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["front"]);
|
||||
|
||||
return (
|
||||
<div className="stack items-start">
|
||||
<span className={styles.firstPlacersTeamName}>
|
||||
{censored ? "???" : placer.teamName}
|
||||
</span>
|
||||
<div className="text-xxxs text-lighter font-bold text-uppercase">
|
||||
{t("front:showcase.card.winner")}
|
||||
{placer.div ? ` (${placer.div})` : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export default function CalendarEventPage() {
|
|||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
.navigateButtonsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -31,6 +32,9 @@
|
|||
|
||||
&:not(.navigateArrowButton) {
|
||||
justify-content: center;
|
||||
flex-basis: 100%;
|
||||
background-color: var(--color-bg);
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
& svg {
|
||||
|
|
@ -50,7 +54,26 @@
|
|||
}
|
||||
}
|
||||
|
||||
.navigateArrowButtonRange {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
@container (width >= 640px) {
|
||||
.navigateArrowButton {
|
||||
min-width: 7.25rem;
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.navigateButton:not(.navigateArrowButton) {
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.navigateArrowButton {
|
||||
gap: var(--s-1);
|
||||
|
||||
& svg {
|
||||
margin-inline-start: -5px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -598,7 +598,7 @@ function TagsAdder() {
|
|||
|
||||
const tagsForSelect = CALENDAR_EVENT.TAGS.filter(
|
||||
(tag) => !tags.includes(tag),
|
||||
).filter((tag) => tag !== "SZ" && tag !== "TW"); // TODO: these are now added automatically, remove in migration?
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
|
|
|
|||
|
|
@ -144,17 +144,16 @@ function NavigateButton({
|
|||
daysInterval: ReturnType<typeof daysForCalendar>["shown"];
|
||||
filters?: CalendarLoaderData["filters"];
|
||||
}) {
|
||||
const { formatDate } = useTimeFormat();
|
||||
const { formatDateRange } = useTimeFormat();
|
||||
const lowestDate = daysInterval[0];
|
||||
const highestDate = daysInterval[daysInterval.length - 1];
|
||||
|
||||
const dateToString = (
|
||||
day: ReturnType<typeof daysForCalendar>["shown"][number],
|
||||
) =>
|
||||
formatDate(new Date(new Date().getFullYear(), day.month, day.day), {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
const year = new Date().getFullYear();
|
||||
const rangeString = formatDateRange(
|
||||
new Date(year, lowestDate.month, lowestDate.day),
|
||||
new Date(year, highestDate.month, highestDate.day),
|
||||
{ day: "numeric", month: "numeric" },
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -165,9 +164,7 @@ function NavigateButton({
|
|||
{icon}
|
||||
<div>
|
||||
<div>{children}</div>
|
||||
<div className="text-xxs text-lighter">
|
||||
{dateToString(lowestDate)} - {dateToString(highestDate)}
|
||||
</div>
|
||||
<div className={styles.navigateArrowButtonRange}>{rangeString}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
|
@ -264,7 +261,7 @@ function DayHeader(props: { date: number; month: number; year: number }) {
|
|||
>
|
||||
{formatDate(date, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
})}
|
||||
<div className={styles.dayHeaderWeekday}>
|
||||
{formatDate(date, {
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ function ChatProviderInner({
|
|||
"system:",
|
||||
isSystemMessage,
|
||||
);
|
||||
if (isSystemMessage) {
|
||||
if (isSystemMessage || messageArr[0].revalidateOnly) {
|
||||
revalidate();
|
||||
}
|
||||
|
||||
|
|
@ -684,7 +684,7 @@ function useChatRouteSync({
|
|||
]);
|
||||
}
|
||||
|
||||
function useCurrentRouteChatCode(): string | string[] | null {
|
||||
export function useCurrentRouteChatCode(): string | string[] | null {
|
||||
const matches = useMatches();
|
||||
|
||||
for (const match of matches) {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ export async function setMetadata(args: SetMetadataArgs) {
|
|||
args.participantUserIds,
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Setting chat room metadata for ${args.chatCode} (participants: ${participantsKey})`,
|
||||
);
|
||||
|
||||
return void fetch(process.env.SKALOP_SYSTEM_MESSAGE_URL, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
58
app/features/chat/RoomLinkRepository.server.ts
Normal file
58
app/features/chat/RoomLinkRepository.server.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { sub } from "date-fns";
|
||||
import { db } from "~/db/sql";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
|
||||
export function upsert(args: { userId: number; url: string }) {
|
||||
return db
|
||||
.insertInto("RoomLink")
|
||||
.values({
|
||||
userId: args.userId,
|
||||
url: args.url,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column("userId").doUpdateSet({
|
||||
url: args.url,
|
||||
createdAt: databaseTimestampNow(),
|
||||
refreshedAt: databaseTimestampNow(),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function findByUserIds(userIds: number[], maxAgeHours: number) {
|
||||
return db
|
||||
.selectFrom("RoomLink")
|
||||
.select([
|
||||
"RoomLink.userId",
|
||||
"RoomLink.url",
|
||||
"RoomLink.createdAt",
|
||||
"RoomLink.refreshedAt",
|
||||
])
|
||||
.where("RoomLink.userId", "in", userIds)
|
||||
.where(
|
||||
"RoomLink.createdAt",
|
||||
">=",
|
||||
dateToDatabaseTimestamp(sub(new Date(), { hours: maxAgeHours })),
|
||||
)
|
||||
.orderBy("RoomLink.refreshedAt", "asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function refreshTimestamp(userId: number) {
|
||||
return db
|
||||
.updateTable("RoomLink")
|
||||
.set({ refreshedAt: databaseTimestampNow() })
|
||||
.where("userId", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function deleteOld() {
|
||||
return db
|
||||
.deleteFrom("RoomLink")
|
||||
.where(
|
||||
"refreshedAt",
|
||||
"<",
|
||||
dateToDatabaseTimestamp(sub(new Date(), { hours: 2 })),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
117
app/features/chat/chat-constants.test.ts
Normal file
117
app/features/chat/chat-constants.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
extractRoomLink,
|
||||
findRoomLinks,
|
||||
isSplatnetRoomUrl,
|
||||
} from "./chat-constants";
|
||||
|
||||
describe("isSplatnetRoomUrl", () => {
|
||||
test("accepts canonical SplatNet share path", () => {
|
||||
expect(isSplatnetRoomUrl("https://s.nintendo.com/av5ja-lp1/abc123")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts a simple alphanumeric path", () => {
|
||||
expect(isSplatnetRoomUrl("https://s.nintendo.com/abcdef")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects http (non-https)", () => {
|
||||
expect(isSplatnetRoomUrl("http://s.nintendo.com/abc")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects unescaped-dot lookalike host (sanintendoacom.evil.tld)", () => {
|
||||
expect(isSplatnetRoomUrl("https://sanintendoacom.evil.tld/lobby")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects dash variant host (s-nintendo-com.evil.tld)", () => {
|
||||
expect(isSplatnetRoomUrl("https://s-nintendo-com.evil.tld/lobby")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects userinfo in URL (s.nintendo.com@evil.com)", () => {
|
||||
expect(isSplatnetRoomUrl("https://s.nintendo.com@evil.com/abc")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects custom port", () => {
|
||||
expect(isSplatnetRoomUrl("https://s.nintendo.com:8080/abc")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects query string", () => {
|
||||
expect(
|
||||
isSplatnetRoomUrl("https://s.nintendo.com/abc?redirect=evil.com"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects fragment", () => {
|
||||
expect(isSplatnetRoomUrl("https://s.nintendo.com/abc#@evil.com")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects trailing dot in hostname", () => {
|
||||
expect(isSplatnetRoomUrl("https://s.nintendo.com./abc")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty path", () => {
|
||||
expect(isSplatnetRoomUrl("https://s.nintendo.com/")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects path with disallowed characters", () => {
|
||||
expect(isSplatnetRoomUrl("https://s.nintendo.com/abc!def")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects malformed URL", () => {
|
||||
expect(isSplatnetRoomUrl("not a url")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findRoomLinks", () => {
|
||||
test("returns empty array when no links", () => {
|
||||
expect(findRoomLinks("just chatting here")).toEqual([]);
|
||||
});
|
||||
|
||||
test("finds a valid link with its index", () => {
|
||||
const text = "join: https://s.nintendo.com/abc123 thanks";
|
||||
expect(findRoomLinks(text)).toEqual([
|
||||
{ url: "https://s.nintendo.com/abc123", index: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores spoofed lookalike hosts even when surrounding text matches the candidate regex", () => {
|
||||
const text = "join here https://sanintendoacom.evil.tld/lobby right now";
|
||||
expect(findRoomLinks(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores links with query strings", () => {
|
||||
const text =
|
||||
"https://s.nintendo.com/abc?redirect=https://evil.com legitimate?";
|
||||
expect(findRoomLinks(text)).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns multiple valid links", () => {
|
||||
const text =
|
||||
"https://s.nintendo.com/aaa and also https://s.nintendo.com/bbb";
|
||||
expect(findRoomLinks(text)).toEqual([
|
||||
{ url: "https://s.nintendo.com/aaa", index: 0 },
|
||||
{ url: "https://s.nintendo.com/bbb", index: 36 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractRoomLink", () => {
|
||||
test("returns first valid link", () => {
|
||||
expect(extractRoomLink("hi https://s.nintendo.com/abc see you")).toBe(
|
||||
"https://s.nintendo.com/abc",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null when no valid link present", () => {
|
||||
expect(extractRoomLink("https://sanintendoacom.evil.tld/abc")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1 +1,44 @@
|
|||
export const MESSAGE_MAX_LENGTH = 200;
|
||||
|
||||
const SPLATNET_ROOM_HOST = "s.nintendo.com";
|
||||
const SPLATNET_ROOM_PATH_PATTERN = /^\/[A-Za-z0-9/_-]+$/;
|
||||
const SPLATNET_ROOM_CANDIDATE_PATTERN = /https:\/\/s\.nintendo\.com\/\S+/g;
|
||||
|
||||
export function isSplatnetRoomUrl(url: string): boolean {
|
||||
if (!URL.canParse(url)) return false;
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
parsed.protocol === "https:" &&
|
||||
parsed.hostname === SPLATNET_ROOM_HOST &&
|
||||
parsed.username === "" &&
|
||||
parsed.password === "" &&
|
||||
parsed.port === "" &&
|
||||
parsed.search === "" &&
|
||||
parsed.hash === "" &&
|
||||
SPLATNET_ROOM_PATH_PATTERN.test(parsed.pathname)
|
||||
);
|
||||
}
|
||||
|
||||
export function findRoomLinks(
|
||||
text: string,
|
||||
): Array<{ url: string; index: number }> {
|
||||
const results: Array<{ url: string; index: number }> = [];
|
||||
for (const match of text.matchAll(SPLATNET_ROOM_CANDIDATE_PATTERN)) {
|
||||
if (isSplatnetRoomUrl(match[0])) {
|
||||
results.push({ url: match[0], index: match.index });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function extractRoomLink(text: string): string | null {
|
||||
return findRoomLinks(text)[0]?.url ?? null;
|
||||
}
|
||||
|
||||
const MATCH_ROOM_URL_PATTERN =
|
||||
/^\/q\/match\/\d+$|^\/to\/\d+\/matches\/\d+$|^\/scrims\/\d+$/;
|
||||
|
||||
export function isMatchRoomUrl(url: string) {
|
||||
const pathname = URL.canParse(url) ? new URL(url).pathname : url;
|
||||
return MATCH_ROOM_URL_PATTERN.test(pathname);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type SystemMessageType =
|
|||
| "SCORE_CONFIRMED"
|
||||
| "CANCEL_REPORTED"
|
||||
| "CANCEL_CONFIRMED"
|
||||
| "CANCEL_REFUSED"
|
||||
| "TOURNAMENT_UPDATED"
|
||||
| "TOURNAMENT_MATCH_UPDATED";
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,12 @@
|
|||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.roomLink {
|
||||
color: var(--color-text-accent);
|
||||
text-decoration: underline;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.roomButton {
|
||||
border: 0;
|
||||
border-bottom: var(--border-style);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Avatar } from "../../../components/Avatar";
|
|||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SubmitButton } from "../../../components/SubmitButton";
|
||||
import { useTimeFormat } from "../../../hooks/useTimeFormat";
|
||||
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
|
||||
import { findRoomLinks, MESSAGE_MAX_LENGTH } from "../chat-constants";
|
||||
import { useChatAutoScroll } from "../chat-hooks";
|
||||
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
|
||||
import styles from "./Chat.module.css";
|
||||
|
|
@ -95,6 +95,9 @@ export function Chat({
|
|||
case "CANCEL_CONFIRMED": {
|
||||
return t("common:chat.systemMsg.cancelConfirmed", { name: name() });
|
||||
}
|
||||
case "CANCEL_REFUSED": {
|
||||
return t("common:chat.systemMsg.cancelRefused", { name: name() });
|
||||
}
|
||||
case "USER_LEFT": {
|
||||
return t("common:chat.systemMsg.userLeft", { name: name() });
|
||||
}
|
||||
|
|
@ -268,7 +271,9 @@ function Message({
|
|||
[styles.messageContentsPending]: message.pending,
|
||||
})}
|
||||
>
|
||||
{message.contents}
|
||||
{message.contents ? (
|
||||
<MessageContents text={message.contents} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -301,6 +306,39 @@ function SystemMessage({
|
|||
);
|
||||
}
|
||||
|
||||
function MessageContents({ text }: { text: string }) {
|
||||
const matches = findRoomLinks(text);
|
||||
|
||||
if (matches.length === 0) return <>{text}</>;
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const [i, match] of matches.entries()) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
parts.push(
|
||||
<a
|
||||
key={i}
|
||||
href={match.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.roomLink}
|
||||
>
|
||||
{match.url}
|
||||
</a>,
|
||||
);
|
||||
lastIndex = match.index + match.url.length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return <>{parts}</>;
|
||||
}
|
||||
|
||||
function MessageTimestamp({ timestamp }: { timestamp: number }) {
|
||||
const { formatDateTime, formatTime } = useTimeFormat();
|
||||
const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);
|
||||
|
|
|
|||
81
app/features/chat/room-link-utils.ts
Normal file
81
app/features/chat/room-link-utils.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { differenceInMinutes } from "date-fns";
|
||||
import { useFetcher } from "react-router";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
||||
interface RoomLink {
|
||||
userId: number;
|
||||
url: string;
|
||||
refreshedAt: number;
|
||||
}
|
||||
|
||||
interface ResolveActiveRoomLinkArgs {
|
||||
/** Room links for all match participants, sorted by `refreshedAt` ascending. */
|
||||
roomLinks: ReadonlyArray<RoomLink>;
|
||||
/** Database timestamp before which a link is considered stale (e.g. match start time). */
|
||||
freshnessCutoff: number;
|
||||
/** Viewer user id, used as fallback to surface the viewer's own stale link. */
|
||||
viewerUserId?: number;
|
||||
/** Members shown to resolve `hostedBy`. */
|
||||
members: ReadonlyArray<{ id: number; username: string }>;
|
||||
}
|
||||
|
||||
interface ActiveRoomLink {
|
||||
joinLink?: string;
|
||||
hostedBy?: string;
|
||||
isStale?: boolean;
|
||||
staleMinutesAgo: number;
|
||||
refreshedAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the room link to display for a match. Prefers the oldest link refreshed
|
||||
* after the freshness cutoff (the host's confirmed room). Falls back to the
|
||||
* viewer's own stale link so they can refresh it themselves.
|
||||
*/
|
||||
export function resolveActiveRoomLink({
|
||||
roomLinks,
|
||||
freshnessCutoff,
|
||||
viewerUserId,
|
||||
members,
|
||||
}: ResolveActiveRoomLinkArgs): ActiveRoomLink {
|
||||
const validRoomLink = roomLinks.find(
|
||||
(rl) => rl.refreshedAt >= freshnessCutoff,
|
||||
);
|
||||
const ownStaleRoomLink = validRoomLink
|
||||
? undefined
|
||||
: roomLinks.find((rl) => rl.userId === viewerUserId);
|
||||
|
||||
const activeRoomLink = validRoomLink ?? ownStaleRoomLink;
|
||||
|
||||
return {
|
||||
joinLink: activeRoomLink?.url,
|
||||
hostedBy: activeRoomLink
|
||||
? members.find((m) => m.id === activeRoomLink.userId)?.username
|
||||
: undefined,
|
||||
isStale: activeRoomLink ? !validRoomLink : undefined,
|
||||
staleMinutesAgo: ownStaleRoomLink
|
||||
? differenceInMinutes(
|
||||
new Date(),
|
||||
databaseTimestampToDate(ownStaleRoomLink.refreshedAt),
|
||||
)
|
||||
: 0,
|
||||
refreshedAt: validRoomLink
|
||||
? databaseTimestampToDate(validRoomLink.refreshedAt)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Confirms the viewer's room link by refreshing its timestamp via the central `/room` action. */
|
||||
export function useConfirmRoom() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return {
|
||||
onConfirmRoom: () => {
|
||||
fetcher.submit(
|
||||
{ _action: "CONFIRM" },
|
||||
{ method: "post", action: "/room", encType: "application/json" },
|
||||
);
|
||||
},
|
||||
isConfirming: fetcher.state !== "idle",
|
||||
};
|
||||
}
|
||||
37
app/features/chat/routes/room.ts
Normal file
37
app/features/chat/routes/room.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { parseRequestPayload } from "~/utils/remix.server";
|
||||
import { isSplatnetRoomUrl } from "../chat-constants";
|
||||
import * as RoomLinkRepository from "../RoomLinkRepository.server";
|
||||
|
||||
const roomLinkSchema = z.discriminatedUnion("_action", [
|
||||
z.object({
|
||||
_action: z.literal("UPSERT"),
|
||||
url: z.string().refine(isSplatnetRoomUrl, "Not a SplatNet room URL"),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("CONFIRM"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: roomLinkSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "UPSERT": {
|
||||
await RoomLinkRepository.upsert({ userId: user.id, url: data.url });
|
||||
break;
|
||||
}
|
||||
case "CONFIRM": {
|
||||
await RoomLinkRepository.refreshTimestamp(user.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -49,6 +49,21 @@ export function getLiveTournamentStreams(): SidebarStream[] {
|
|||
return streams;
|
||||
}
|
||||
|
||||
/** Lowercased Twitch usernames of all members and casters streaming a currently live tournament. */
|
||||
export function getLiveTournamentStreamerTwitchNames(): string[] {
|
||||
const names: string[] = [];
|
||||
|
||||
for (const tournament of RunningTournaments.all) {
|
||||
if (tournament.isLeagueDivision) continue;
|
||||
|
||||
for (const stream of tournament.streams) {
|
||||
names.push(stream.twitchUserName.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function deriveCurrentRound(tournament: Tournament): string {
|
||||
for (const bracket of tournament.brackets.toReversed()) {
|
||||
if (bracket.preview) continue;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "~/components/elements/Menu";
|
||||
import { ListButton } from "~/components/SideNav";
|
||||
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { SENDOUQ_LOOKING_PAGE, tournamentSubsPage } from "~/utils/urls";
|
||||
|
||||
|
|
@ -38,12 +39,17 @@ export function FriendMenu({
|
|||
onNavigate?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common", "friends"]);
|
||||
const { formatDate } = useTimeFormat();
|
||||
const fetcher = useFetcher();
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||
|
||||
const friendSinceText = friendshipCreatedAt
|
||||
? t("friends:friendsList.friendSince", {
|
||||
date: databaseTimestampToDate(friendshipCreatedAt).toLocaleDateString(),
|
||||
date: formatDate(databaseTimestampToDate(friendshipCreatedAt), {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
}),
|
||||
})
|
||||
: null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import cachified from "@epic-web/cachified";
|
||||
import * as R from "remeda";
|
||||
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import {
|
||||
getBracketProgressionLabel,
|
||||
tournamentIsRanked,
|
||||
} from "~/features/tournament/tournament-utils";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||
import {
|
||||
|
|
@ -186,16 +188,21 @@ function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) {
|
|||
const threeDaysAgo = databaseTimestampThreeDaysAgo();
|
||||
const nonResults = tournaments.filter(
|
||||
(tournament) =>
|
||||
!tournament.firstPlacer &&
|
||||
tournament.firstPlacers.length === 0 &&
|
||||
!tournament.isFinalized &&
|
||||
tournament.startTime > threeDaysAgo,
|
||||
);
|
||||
|
||||
const rankedResults = tournaments
|
||||
.filter((tournament) => tournament.firstPlacer && tournament.isRanked)
|
||||
.filter(
|
||||
(tournament) => tournament.firstPlacers.length > 0 && tournament.isRanked,
|
||||
)
|
||||
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
|
||||
const nonRankedResults = tournaments
|
||||
.filter((tournament) => tournament.firstPlacer && !tournament.isRanked)
|
||||
.filter(
|
||||
(tournament) =>
|
||||
tournament.firstPlacers.length > 0 && !tournament.isRanked,
|
||||
)
|
||||
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
|
||||
|
||||
const rankedResultsToKeep = rankedResults.slice(0, 4);
|
||||
|
|
@ -283,7 +290,7 @@ const MEMBERS_TO_SHOW = 5;
|
|||
function mapTournamentFromDB(
|
||||
tournament: TournamentRepository.ForShowcase,
|
||||
): ShowcaseCalendarEvent {
|
||||
const highestDivWinners = resolveHighestDivisionWinners(tournament);
|
||||
const firstPlacers = resolveFirstPlacers(tournament);
|
||||
|
||||
const tentativeTier =
|
||||
tournament.tier === null &&
|
||||
|
|
@ -320,41 +327,35 @@ function mapTournamentFromDB(
|
|||
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
|
||||
modes: null,
|
||||
hasVods: (tournament.vodCount ?? 0) > 0,
|
||||
firstPlacer:
|
||||
highestDivWinners.length > 0
|
||||
? {
|
||||
teamName: highestDivWinners[0].teamName,
|
||||
logoUrl:
|
||||
highestDivWinners[0].teamLogoUrl ??
|
||||
highestDivWinners[0].pickupAvatarUrl,
|
||||
div: highestDivWinners[0].div,
|
||||
members: highestDivWinners
|
||||
.slice(0, MEMBERS_TO_SHOW)
|
||||
.map((firstPlacer) => ({
|
||||
customUrl: firstPlacer.customUrl,
|
||||
discordAvatar: firstPlacer.discordAvatar,
|
||||
discordId: firstPlacer.discordId,
|
||||
id: firstPlacer.id,
|
||||
username: firstPlacer.username,
|
||||
country: firstPlacer.country,
|
||||
})),
|
||||
notShownMembersCount:
|
||||
highestDivWinners.length > MEMBERS_TO_SHOW
|
||||
? highestDivWinners.length - MEMBERS_TO_SHOW
|
||||
: 0,
|
||||
}
|
||||
: null,
|
||||
firstPlacers,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveHighestDivisionWinners(
|
||||
type FirstPlacerRow = TournamentRepository.ForShowcase["firstPlacers"][number];
|
||||
|
||||
function resolveFirstPlacers(
|
||||
tournament: TournamentRepository.ForShowcase,
|
||||
) {
|
||||
): ShowcaseCalendarEvent["firstPlacers"] {
|
||||
if (tournament.firstPlacers.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// not a "many starting brackets" tournament
|
||||
if (
|
||||
Progression.hasAbDivisionsFinals(tournament.settings.bracketProgression)
|
||||
) {
|
||||
const byDiv = R.groupBy(tournament.firstPlacers, (p) => p.div ?? "");
|
||||
return Object.values(byDiv)
|
||||
.map((rows) => buildFirstPlacerEntry(rows, { withMembers: false }))
|
||||
.sort((a, b) => (a.div ?? "").localeCompare(b.div ?? ""));
|
||||
}
|
||||
|
||||
const winnerRows = winnersOfHighestDivision(tournament);
|
||||
return [buildFirstPlacerEntry(winnerRows, { withMembers: true })];
|
||||
}
|
||||
|
||||
function winnersOfHighestDivision(
|
||||
tournament: TournamentRepository.ForShowcase,
|
||||
): FirstPlacerRow[] {
|
||||
if (tournament.firstPlacers.every((p) => p.div === null)) {
|
||||
return tournament.firstPlacers;
|
||||
}
|
||||
|
|
@ -363,8 +364,6 @@ function resolveHighestDivisionWinners(
|
|||
0,
|
||||
tournament.settings.bracketProgression,
|
||||
);
|
||||
|
||||
// Filter to only include winners from the highest division
|
||||
const highestDivWinners = tournament.firstPlacers.filter(
|
||||
(p) => p.div === highestDivName,
|
||||
);
|
||||
|
|
@ -374,6 +373,34 @@ function resolveHighestDivisionWinners(
|
|||
: tournament.firstPlacers;
|
||||
}
|
||||
|
||||
function buildFirstPlacerEntry(
|
||||
rows: FirstPlacerRow[],
|
||||
{ withMembers }: { withMembers: boolean },
|
||||
): ShowcaseCalendarEvent["firstPlacers"][number] {
|
||||
const first = rows[0];
|
||||
const members = withMembers
|
||||
? rows.slice(0, MEMBERS_TO_SHOW).map((row) => ({
|
||||
customUrl: row.customUrl,
|
||||
discordAvatar: row.discordAvatar,
|
||||
discordId: row.discordId,
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
country: row.country,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
teamName: first.teamName,
|
||||
logoUrl: first.teamLogoUrl ?? first.pickupAvatarUrl,
|
||||
div: first.div,
|
||||
members,
|
||||
notShownMembersCount:
|
||||
withMembers && rows.length > MEMBERS_TO_SHOW
|
||||
? rows.length - MEMBERS_TO_SHOW
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function databaseTimestampWeekFromNow() {
|
||||
const now = new Date();
|
||||
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ function SeasonDates({
|
|||
|
||||
return isHydrated ? (
|
||||
<div className={className}>
|
||||
{formatDate(season.starts, { month: "long", day: "numeric" })} -{" "}
|
||||
{formatDate(season.ends, { month: "long", day: "numeric" })}
|
||||
{formatDate(season.starts, { month: "numeric", day: "numeric" })} -{" "}
|
||||
{formatDate(season.ends, { month: "numeric", day: "numeric" })}
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx(className, "invisible")}>X</div>
|
||||
|
|
|
|||
|
|
@ -410,12 +410,7 @@ export async function seasonPopularUsersWeapon(
|
|||
.with("q1", (db) =>
|
||||
db
|
||||
.selectFrom("ReportedWeapon")
|
||||
.innerJoin(
|
||||
"GroupMatchMap",
|
||||
"ReportedWeapon.groupMatchMapId",
|
||||
"GroupMatchMap.id",
|
||||
)
|
||||
.innerJoin("GroupMatch", "GroupMatchMap.matchId", "GroupMatch.id")
|
||||
.innerJoin("GroupMatch", "ReportedWeapon.groupMatchId", "GroupMatch.id")
|
||||
.select(({ fn }) => [
|
||||
"ReportedWeapon.userId",
|
||||
"ReportedWeapon.weaponSplId",
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ function PostTime({
|
|||
return (
|
||||
<div className="text-lighter text-xs font-bold">
|
||||
{formatDate(createdAtDate, {
|
||||
month: "long",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
{overDayDifferenceBetween ? (
|
||||
|
|
|
|||
732
app/features/match-page-test/routes/match-page-test.tsx
Normal file
732
app/features/match-page-test/routes/match-page-test.tsx
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
import { ArrowLeft, Ban, Undo2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { Main } from "~/components/Main";
|
||||
import { MatchActionPickBanTab } from "~/components/match-page/MatchActionPickBanTab";
|
||||
import { MatchActionTab } from "~/components/match-page/MatchActionTab";
|
||||
import {
|
||||
IconBanner,
|
||||
MatchBannerContainer,
|
||||
} from "~/components/match-page/MatchBanner";
|
||||
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
|
||||
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
|
||||
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
|
||||
import { MatchPage } from "~/components/match-page/MatchPage";
|
||||
import { MatchPageHeader } from "~/components/match-page/MatchPageHeader";
|
||||
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
|
||||
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
|
||||
import { MatchTabs } from "~/components/match-page/MatchTabs";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
|
||||
type ActionVariant =
|
||||
| "winner"
|
||||
| "counterpick-stage"
|
||||
| "ban-stage"
|
||||
| "ban-stage-only"
|
||||
| "pick-mode"
|
||||
| "ban-mode";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
};
|
||||
|
||||
export default function MatchPageTestRoute() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const [actionVariant, setActionVariant] = useState<ActionVariant>("winner");
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<MatchPage>
|
||||
<MatchPageHeader
|
||||
subtitle="Mola Mola"
|
||||
topRight={
|
||||
<SendouButton variant="outlined" size="small" icon={<ArrowLeft />}>
|
||||
Back to bracket
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
Round 2.1
|
||||
</MatchPageHeader>
|
||||
|
||||
<SendouTabs
|
||||
selectedKey={actionVariant}
|
||||
onSelectionChange={(key) => setActionVariant(key as ActionVariant)}
|
||||
disappearing={false}
|
||||
padded={false}
|
||||
>
|
||||
<SendouTabList>
|
||||
<SendouTab id="winner">Winner</SendouTab>
|
||||
<SendouTab id="counterpick-stage">Counterpick</SendouTab>
|
||||
<SendouTab id="ban-stage">Ban stage</SendouTab>
|
||||
<SendouTab id="ban-stage-only">Ban stage (any mode)</SendouTab>
|
||||
<SendouTab id="pick-mode">Pick mode</SendouTab>
|
||||
<SendouTab id="ban-mode">Ban mode</SendouTab>
|
||||
</SendouTabList>
|
||||
</SendouTabs>
|
||||
|
||||
<MatchBannerContainer>
|
||||
<MatchBannerTopRow
|
||||
score={{
|
||||
alpha: 1,
|
||||
bravo: 2,
|
||||
isFinal: false,
|
||||
count: 5,
|
||||
bestOf: true,
|
||||
}}
|
||||
time={{
|
||||
currentMinutes: 3,
|
||||
totalMinutes: 1,
|
||||
}}
|
||||
/>
|
||||
<IconBanner
|
||||
icon={<Ban size={32} />}
|
||||
header={t("q:match.cancelRequested")}
|
||||
subtitle={t("q:match.cancelRequested.subtitle", {
|
||||
teamName: "Chimera",
|
||||
})}
|
||||
screenLegal={false}
|
||||
/>
|
||||
<MatchBannerBottomRow
|
||||
games={[
|
||||
{ mode: "SZ", winner: "ALPHA" },
|
||||
{ mode: "TC", winner: "BRAVO" },
|
||||
{ mode: "RM", winner: "ALPHA" },
|
||||
]}
|
||||
activeRosters={{
|
||||
alpha: [
|
||||
{
|
||||
id: 1,
|
||||
username: "Sendou",
|
||||
discordId: "123",
|
||||
discordAvatar: null,
|
||||
customUrl: "sendou",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "Lean",
|
||||
discordId: "456",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: "Kiver",
|
||||
discordId: "789",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: "Brian",
|
||||
discordId: "012",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
],
|
||||
bravo: [
|
||||
{
|
||||
id: 5,
|
||||
username: "Naga",
|
||||
discordId: "345",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
username: "Grey",
|
||||
discordId: "678",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
username: "Zack",
|
||||
discordId: "901",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
username: "Lime",
|
||||
discordId: "234",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</MatchBannerContainer>
|
||||
|
||||
<MatchTabs tabs={["join", "rosters", "action", "result"]}>
|
||||
<MatchJoinTab
|
||||
joinLink="https://app.nintendo.net/private_battle/abc123"
|
||||
pool="SQ7"
|
||||
pass="8430"
|
||||
showNoSplatnetAlert
|
||||
/>
|
||||
<MatchRosterTab
|
||||
minMembersPerTeam={4}
|
||||
canEditSubbedOut={[true, false]}
|
||||
onSubbedOutChange={(teamId, subbedOut) => {
|
||||
logger.info("onSubbedOutChange", { teamId, subbedOut });
|
||||
}}
|
||||
teams={[
|
||||
{
|
||||
team: {
|
||||
id: 1,
|
||||
name: "me in japan",
|
||||
url: "/t/me-in-japan",
|
||||
},
|
||||
tier: { name: "DIAMOND", isPlus: true },
|
||||
members: [
|
||||
{
|
||||
id: 1,
|
||||
username: "Sendou",
|
||||
discordId: "123",
|
||||
discordAvatar: null,
|
||||
customUrl: "sendou",
|
||||
tier: { name: "LEVIATHAN", isPlus: true },
|
||||
plusTier: 1,
|
||||
weaponPool: [0, 2000, 4000],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "Lean",
|
||||
discordId: "456",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
tier: { name: "DIAMOND", isPlus: false },
|
||||
plusTier: 2,
|
||||
weaponPool: [20, 1100],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: "Kiver",
|
||||
discordId: "789",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
tier: "CALCULATING",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: "Brian",
|
||||
discordId: "012",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
username: "Poppy",
|
||||
discordId: "567",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
tier: { name: "GOLD", isPlus: true },
|
||||
},
|
||||
],
|
||||
subbedOut: [9],
|
||||
},
|
||||
{
|
||||
defaultName: "Group Bravo",
|
||||
members: [
|
||||
{
|
||||
id: 5,
|
||||
username: "Naga",
|
||||
discordId: "345",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
tier: { name: "PLATINUM", isPlus: false },
|
||||
plusTier: 3,
|
||||
weaponPool: [40, 3000],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
username: "Grey",
|
||||
discordId: "678",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
tier: { name: "SILVER", isPlus: true },
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
username: "Zack",
|
||||
discordId: "901",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
username: "Lime",
|
||||
discordId: "234",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
tier: { name: "BRONZE", isPlus: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{actionVariant === "winner" ? (
|
||||
<MatchActionTab
|
||||
teams={[
|
||||
{ id: 1, name: "Chimera" },
|
||||
{ id: 2, name: "Koopa Clan" },
|
||||
]}
|
||||
ownTeamId={1}
|
||||
stageId={4}
|
||||
mode="SZ"
|
||||
withPoints={true}
|
||||
actionButtons={
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
icon={<Undo2 size={16} />}
|
||||
>
|
||||
{t("q:match.undoReport")}
|
||||
</SendouButton>
|
||||
}
|
||||
/>
|
||||
) : actionVariant === "counterpick-stage" ? (
|
||||
<MatchActionPickBanTab
|
||||
type="PICK"
|
||||
options={[
|
||||
{ stageId: 1, mode: "SZ", picker: "US" },
|
||||
{ stageId: 2, mode: "SZ", picker: "BOTH" },
|
||||
{ stageId: 3, mode: "SZ", picker: "THEM" },
|
||||
{ stageId: 4, mode: "TC", picker: "US" },
|
||||
{ stageId: 5, mode: "TC", picker: "THEM" },
|
||||
{ stageId: 6, mode: "RM", picker: "BOTH" },
|
||||
{ stageId: 7, mode: "RM", picker: "US" },
|
||||
]}
|
||||
onSubmit={(data) => logger.info("pick submit", data)}
|
||||
/>
|
||||
) : actionVariant === "ban-stage" ? (
|
||||
<MatchActionPickBanTab
|
||||
type="BAN"
|
||||
options={[
|
||||
{ stageId: 1, mode: "SZ", nth: 1 },
|
||||
{ stageId: 2, mode: "SZ", nth: 2 },
|
||||
{ stageId: 4, mode: "TC", nth: 3 },
|
||||
{ stageId: 5, mode: "TC", nth: 4 },
|
||||
{ stageId: 6, mode: "RM", nth: 5 },
|
||||
{ stageId: 7, mode: "RM", nth: 6 },
|
||||
{ stageId: 8, mode: "CB", nth: 7 },
|
||||
{ stageId: 9, mode: "CB", nth: 8 },
|
||||
]}
|
||||
onSubmit={(data) => logger.info("ban submit", data)}
|
||||
/>
|
||||
) : actionVariant === "ban-stage-only" ? (
|
||||
<MatchActionPickBanTab
|
||||
type="BAN"
|
||||
options={[
|
||||
{ stageId: 1 },
|
||||
{ stageId: 2 },
|
||||
{ stageId: 3 },
|
||||
{ stageId: 4 },
|
||||
{ stageId: 5 },
|
||||
{ stageId: 6 },
|
||||
{ stageId: 7 },
|
||||
{ stageId: 8 },
|
||||
{ stageId: 9 },
|
||||
]}
|
||||
onSubmit={(data) => logger.info("ban stage-only submit", data)}
|
||||
/>
|
||||
) : actionVariant === "pick-mode" ? (
|
||||
<MatchActionPickBanTab
|
||||
type="PICK"
|
||||
options={[
|
||||
{ mode: "SZ" },
|
||||
{ mode: "TC" },
|
||||
{ mode: "RM" },
|
||||
{ mode: "CB" },
|
||||
]}
|
||||
onSubmit={(data) => logger.info("pick mode submit", data)}
|
||||
/>
|
||||
) : (
|
||||
<MatchActionPickBanTab
|
||||
type="BAN"
|
||||
options={[
|
||||
{ mode: "SZ" },
|
||||
{ mode: "TC" },
|
||||
{ mode: "RM" },
|
||||
{ mode: "CB" },
|
||||
]}
|
||||
onSubmit={(data) => logger.info("ban mode submit", data)}
|
||||
/>
|
||||
)}
|
||||
<MatchResultTab
|
||||
teams={{
|
||||
alpha: { name: "me in japan" },
|
||||
bravo: { name: "Group Bravo" },
|
||||
}}
|
||||
score={{ alpha: 3, bravo: 0 }}
|
||||
spChanges={{
|
||||
alpha: {
|
||||
members: [
|
||||
{
|
||||
user: {
|
||||
id: 1,
|
||||
username: "Sendou",
|
||||
discordId: "123",
|
||||
discordAvatar: null,
|
||||
customUrl: "sendou",
|
||||
},
|
||||
skillDifference: {
|
||||
calculated: true,
|
||||
spDiff: 12.3,
|
||||
oldSp: 1402.43,
|
||||
newSp: 1414.73,
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
id: 2,
|
||||
username: "Lean",
|
||||
discordId: "456",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
skillDifference: {
|
||||
calculated: true,
|
||||
spDiff: 8.7,
|
||||
oldSp: 1521.18,
|
||||
newSp: 1529.88,
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
id: 3,
|
||||
username: "Kiver",
|
||||
discordId: "789",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
skillDifference: {
|
||||
calculated: false,
|
||||
matchesCount: 3,
|
||||
matchesCountNeeded: 7,
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
id: 4,
|
||||
username: "Brian",
|
||||
discordId: "012",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
skillDifference: {
|
||||
calculated: false,
|
||||
matchesCount: 7,
|
||||
matchesCountNeeded: 7,
|
||||
newSp: 1850,
|
||||
},
|
||||
},
|
||||
],
|
||||
skillDifference: {
|
||||
calculated: false,
|
||||
matchesCount: 5,
|
||||
matchesCountNeeded: 7,
|
||||
},
|
||||
},
|
||||
bravo: {
|
||||
members: [
|
||||
{
|
||||
user: {
|
||||
id: 5,
|
||||
username: "Naga",
|
||||
discordId: "345",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
skillDifference: {
|
||||
calculated: true,
|
||||
spDiff: -11.2,
|
||||
oldSp: 1612.55,
|
||||
newSp: 1601.35,
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
id: 6,
|
||||
username: "Grey",
|
||||
discordId: "678",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
skillDifference: {
|
||||
calculated: true,
|
||||
spDiff: -9.4,
|
||||
oldSp: 1488.62,
|
||||
newSp: 1479.22,
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
id: 7,
|
||||
username: "Zack",
|
||||
discordId: "901",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
skillDifference: {
|
||||
calculated: true,
|
||||
spDiff: -13.8,
|
||||
oldSp: 1730.91,
|
||||
newSp: 1717.11,
|
||||
},
|
||||
},
|
||||
{
|
||||
user: {
|
||||
id: 8,
|
||||
username: "Lime",
|
||||
discordId: "234",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
skillDifference: {
|
||||
calculated: true,
|
||||
spDiff: -7.6,
|
||||
oldSp: 1555.04,
|
||||
newSp: 1547.44,
|
||||
},
|
||||
},
|
||||
],
|
||||
skillDifference: {
|
||||
calculated: true,
|
||||
oldSp: 1980,
|
||||
newSp: 1968,
|
||||
},
|
||||
},
|
||||
}}
|
||||
maps={[
|
||||
{
|
||||
stageId: 1,
|
||||
mode: "SZ",
|
||||
timestamp: 1712855000,
|
||||
winner: "ALPHA",
|
||||
weapons: {
|
||||
alpha: [40, 10, 1100, 3040],
|
||||
bravo: [50, 210, 2010, 4010],
|
||||
},
|
||||
rosters: {
|
||||
alpha: [
|
||||
{
|
||||
id: 1,
|
||||
username: "Sendou",
|
||||
discordId: "123",
|
||||
discordAvatar: null,
|
||||
customUrl: "sendou",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "Lean",
|
||||
discordId: "456",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: "Kiver",
|
||||
discordId: "789",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: "Brian",
|
||||
discordId: "012",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
],
|
||||
bravo: [
|
||||
{
|
||||
id: 5,
|
||||
username: "Naga",
|
||||
discordId: "345",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
username: "Grey",
|
||||
discordId: "678",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
username: "Zack",
|
||||
discordId: "901",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
username: "Lime",
|
||||
discordId: "234",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
stageId: 4,
|
||||
mode: "TC",
|
||||
timestamp: 1712855600,
|
||||
winner: "ALPHA",
|
||||
weapons: {
|
||||
alpha: [40, 10, 1100, 3040],
|
||||
bravo: [50, 210, 2010, 4010],
|
||||
},
|
||||
rosters: {
|
||||
alpha: [
|
||||
{
|
||||
id: 1,
|
||||
username: "Sendou",
|
||||
discordId: "123",
|
||||
discordAvatar: null,
|
||||
customUrl: "sendou",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "Lean",
|
||||
discordId: "456",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: "Kiver",
|
||||
discordId: "789",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: "Brian",
|
||||
discordId: "012",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
],
|
||||
bravo: [
|
||||
{
|
||||
id: 5,
|
||||
username: "Naga",
|
||||
discordId: "345",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
username: "Grey",
|
||||
discordId: "678",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
username: "Zack",
|
||||
discordId: "901",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
username: "Lime",
|
||||
discordId: "234",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
stageId: 2,
|
||||
mode: "RM",
|
||||
timestamp: 1712856200,
|
||||
winner: "ALPHA",
|
||||
points: [100, 42],
|
||||
weapons: {
|
||||
alpha: [40, null, 1100, 3040],
|
||||
bravo: [null, 210, null, 4010],
|
||||
},
|
||||
rosters: {
|
||||
alpha: [
|
||||
{
|
||||
id: 1,
|
||||
username: "Sendou",
|
||||
discordId: "123",
|
||||
discordAvatar: null,
|
||||
customUrl: "sendou",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: "Lean",
|
||||
discordId: "456",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: "Kiver",
|
||||
discordId: "789",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: "Brian",
|
||||
discordId: "012",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
],
|
||||
bravo: [
|
||||
{
|
||||
id: 5,
|
||||
username: "Naga",
|
||||
discordId: "345",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
username: "Grey",
|
||||
discordId: "678",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
username: "Poppy",
|
||||
discordId: "567",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
username: "Lime",
|
||||
discordId: "234",
|
||||
discordAvatar: null,
|
||||
customUrl: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</MatchTabs>
|
||||
</MatchPage>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,38 @@
|
|||
import { sql } from "kysely";
|
||||
import { sql, type Transaction } from "kysely";
|
||||
import { ordinal } from "openskill";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants";
|
||||
|
||||
export async function addInitialSkill(
|
||||
{
|
||||
mu,
|
||||
sigma,
|
||||
season,
|
||||
userId,
|
||||
}: {
|
||||
mu: number;
|
||||
sigma: number;
|
||||
season: number;
|
||||
userId: number;
|
||||
},
|
||||
trx?: Transaction<DB>,
|
||||
) {
|
||||
const executor = trx ?? db;
|
||||
|
||||
await executor
|
||||
.insertInto("Skill")
|
||||
.values({
|
||||
mu,
|
||||
sigma,
|
||||
season,
|
||||
ordinal: ordinal({ mu, sigma }),
|
||||
userId,
|
||||
matchesCount: 0,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function seasonProgressionByUserId({
|
||||
userId,
|
||||
season,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user