mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-24 06:58:10 -05:00
SendouQ chat (#1508)
* Chat initial component * Connect to websocket * Chat render actual messages * Chat keep scrolled to bottom * Pending messages * Pass rooms as arg * Chat with tabs * Message max length * Looking tabs initial * Fixes * Chat on looking page w/ unread messages count * Add reconnecting websocket * Patron custom color * Change ChatMessage data model * Fix mobile layout * Add clean up to useEffect * Chat codes * FF + clear messages on group morph * Hide messages when user leaves etc. * Fix match page layout when chat missing * New tabs * Tabs for mobile * Add TODOs * Switch to own group tab when roster changes * Chat styling * Redesign group cards * Bring back manager buttons * Remove flipped * Remove unused code * Align better * Link to user profile * Better room pass + highlighted * Fix view when group expired * Fix MemberAdder (missing input + overflow) * Chat stay connected in looking * Remove filters for now * Fix chat number align * Fix chat unseen messages * Hide chat when alone * Remove rest todos
This commit is contained in:
parent
362f5d9a47
commit
0371bfcd12
|
|
@ -21,3 +21,7 @@ STORAGE_URL=
|
|||
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
|
||||
SKALOP_WS_URL=ws://localhost:5900
|
||||
|
||||
FF_ENABLE_CHAT=false
|
||||
|
|
|
|||
316
app/components/Chat.tsx
Normal file
316
app/components/Chat.tsx
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
import { Avatar } from "./Avatar";
|
||||
import * as React from "react";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
import type { User } from "~/db/types";
|
||||
import { useUser } from "~/modules/auth";
|
||||
import { nanoid } from "nanoid";
|
||||
import clsx from "clsx";
|
||||
import { SKALOP_BASE_URL } from "~/utils/urls";
|
||||
import { Button } from "./Button";
|
||||
import ReconnectingWebSocket from "reconnecting-websocket";
|
||||
import invariant from "tiny-invariant";
|
||||
|
||||
type ChatUser = Pick<User, "discordName" | "discordId" | "discordAvatar"> & {
|
||||
chatNameColor: string | null;
|
||||
};
|
||||
|
||||
const MESSAGE_MAX_LENGTH = 200;
|
||||
|
||||
export interface ChatProps {
|
||||
users: Record<number, ChatUser>;
|
||||
rooms: { label: string; code: string }[];
|
||||
className?: string;
|
||||
messagesContainerClassName?: string;
|
||||
hidden?: boolean;
|
||||
onNewMessage?: (message: ChatMessage) => void;
|
||||
}
|
||||
|
||||
export function ConnectedChat(props: ChatProps) {
|
||||
const chat = useChat(props);
|
||||
|
||||
return <Chat {...props} chat={chat} />;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
users,
|
||||
rooms,
|
||||
className,
|
||||
messagesContainerClassName,
|
||||
hidden = false,
|
||||
chat,
|
||||
}: ChatProps & { chat: ReturnType<typeof useChat> }) {
|
||||
const messagesContainerRef = React.useRef<HTMLOListElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
send,
|
||||
messages,
|
||||
currentRoom,
|
||||
setCurrentRoom,
|
||||
connected,
|
||||
unseenMessages,
|
||||
} = chat;
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
send(inputRef.current!.value);
|
||||
inputRef.current!.value = "";
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const messagesContainer = messagesContainerRef.current!;
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<section className={clsx("chat__container", className, { hidden })}>
|
||||
{rooms.length > 1 ? (
|
||||
<div className="stack horizontal">
|
||||
{rooms.map((room, i) => {
|
||||
const unseen = unseenMessages.get(room.code);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={room.code}
|
||||
className={clsx("chat__room-button", {
|
||||
"not-first": i > 0,
|
||||
current: currentRoom === room.code,
|
||||
})}
|
||||
onClick={() => setCurrentRoom(room.code)}
|
||||
>
|
||||
<span className="chat__room-button__unseen invisible" />
|
||||
{room.label}
|
||||
{unseen ? (
|
||||
<span className="chat__room-button__unseen">{unseen}</span>
|
||||
) : (
|
||||
<span className="chat__room-button__unseen invisible" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="chat__input-container">
|
||||
<ol
|
||||
className={clsx("chat__messages", messagesContainerClassName)}
|
||||
ref={messagesContainerRef}
|
||||
>
|
||||
{messages.map((msg) => {
|
||||
const user = msg.userId ? users[msg.userId] : null;
|
||||
if (!user) return null;
|
||||
|
||||
return <Message key={msg.id} user={user} message={msg} />;
|
||||
})}
|
||||
</ol>
|
||||
<form onSubmit={handleSubmit} className="mt-4">
|
||||
<input
|
||||
className="w-full"
|
||||
ref={inputRef}
|
||||
placeholder="Press enter to send"
|
||||
disabled={!connected}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
/>{" "}
|
||||
<div className="chat__bottom-row">
|
||||
{typeof connected !== "boolean" ? (
|
||||
<div />
|
||||
) : connected ? (
|
||||
<div className="text-xxs font-semi-bold text-lighter">
|
||||
Connected
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xxs font-semi-bold text-warning">
|
||||
Disconnected
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton size="tiny" variant="minimal" disabled={!connected}>
|
||||
Send
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({ user, message }: { user: ChatUser; message: ChatMessage }) {
|
||||
return (
|
||||
<li className="chat__message">
|
||||
<Avatar user={user} size="xs" className="my-auto" />
|
||||
<div>
|
||||
<div className="stack horizontal sm">
|
||||
<div
|
||||
className="chat__message__user"
|
||||
style={
|
||||
user.chatNameColor
|
||||
? ({ "--chat-user-color": user.chatNameColor } as any)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{user.discordName}
|
||||
</div>
|
||||
{!message.pending ? (
|
||||
<time className="chat__message__time">
|
||||
{new Date(message.timestamp).toLocaleTimeString()}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={clsx("chat__message__contents", {
|
||||
pending: message.pending,
|
||||
})}
|
||||
>
|
||||
{message.contents}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// export type SystemMessageType = "MANAGER_ADDED" | "MANAGER_REMOVED";
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
// type?: SystemMessageType;
|
||||
contents?: string;
|
||||
// context?: any;
|
||||
userId?: number;
|
||||
timestamp: number;
|
||||
room: string;
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export function useChat({
|
||||
rooms,
|
||||
onNewMessage,
|
||||
}: {
|
||||
rooms: ChatProps["rooms"];
|
||||
onNewMessage?: (message: ChatMessage) => void;
|
||||
}) {
|
||||
const user = useUser();
|
||||
|
||||
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
|
||||
const [connected, setConnected] = React.useState<null | boolean>(null);
|
||||
const [sentMessage, setSentMessage] = React.useState<ChatMessage>();
|
||||
const [currentRoom, setCurrentRoom] = React.useState<string | undefined>(
|
||||
rooms[0]?.code,
|
||||
);
|
||||
|
||||
const ws = React.useRef<ReconnectingWebSocket>();
|
||||
const lastSeenMessagesByRoomId = React.useRef<Map<string, string>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rooms.length === 0) return;
|
||||
|
||||
const url = `${SKALOP_BASE_URL}?${rooms
|
||||
.map((room) => `room=${room.code}`)
|
||||
.join("&")}`;
|
||||
ws.current = new ReconnectingWebSocket(url, [], {
|
||||
maxReconnectionDelay: 10000 * 2,
|
||||
reconnectionDelayGrowFactor: 1.5,
|
||||
});
|
||||
ws.current.onopen = () => {
|
||||
setCurrentRoom(rooms[0].code);
|
||||
setConnected(true);
|
||||
};
|
||||
ws.current.onclose = () => setConnected(false);
|
||||
|
||||
ws.current.onmessage = (e) => {
|
||||
const message = JSON.parse(e.data);
|
||||
const messageArr = Array.isArray(message) ? message : [message];
|
||||
|
||||
const isInitialLoad = Array.isArray(message);
|
||||
|
||||
if (isInitialLoad) {
|
||||
lastSeenMessagesByRoomId.current = message.reduce((acc, cur) => {
|
||||
acc.set(cur.room, cur.id);
|
||||
return acc;
|
||||
}, new Map<string, string>());
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
setMessages(messageArr);
|
||||
} else {
|
||||
onNewMessage?.(message);
|
||||
setMessages((messages) => [...messages, ...messageArr]);
|
||||
}
|
||||
};
|
||||
|
||||
const wsCurrent = ws.current;
|
||||
return () => {
|
||||
wsCurrent?.close();
|
||||
setMessages([]);
|
||||
};
|
||||
}, [rooms, onNewMessage]);
|
||||
|
||||
const send = React.useCallback(
|
||||
(contents: string) => {
|
||||
invariant(currentRoom);
|
||||
|
||||
const id = nanoid();
|
||||
setSentMessage({
|
||||
id,
|
||||
room: currentRoom,
|
||||
contents,
|
||||
timestamp: Date.now(),
|
||||
userId: user!.id,
|
||||
});
|
||||
ws.current!.send(JSON.stringify({ id, contents, room: currentRoom }));
|
||||
},
|
||||
[user, currentRoom],
|
||||
);
|
||||
|
||||
let allMessages = messages;
|
||||
if (sentMessage && !messages.some((msg) => msg.id === sentMessage.id)) {
|
||||
allMessages = [...messages, { ...sentMessage, pending: true }];
|
||||
}
|
||||
|
||||
const roomsMessages = allMessages
|
||||
.filter((msg) => msg.room === currentRoom)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
if (roomsMessages.length > 0 && currentRoom) {
|
||||
lastSeenMessagesByRoomId.current.set(
|
||||
currentRoom,
|
||||
roomsMessages[roomsMessages.length - 1].id,
|
||||
);
|
||||
}
|
||||
|
||||
const unseenMessages = unseenMessagesCountByRoomId({
|
||||
messages,
|
||||
lastSeenMessages: lastSeenMessagesByRoomId.current,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: roomsMessages,
|
||||
send,
|
||||
currentRoom,
|
||||
setCurrentRoom,
|
||||
connected,
|
||||
unseenMessages,
|
||||
};
|
||||
}
|
||||
|
||||
function unseenMessagesCountByRoomId({
|
||||
messages,
|
||||
lastSeenMessages,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
lastSeenMessages: Map<string, string>;
|
||||
}) {
|
||||
const lastUnseenEncountered = new Set<string>();
|
||||
|
||||
const unseenMessages = messages.filter((msg) => {
|
||||
if (msg.id === lastSeenMessages.get(msg.room)) {
|
||||
lastUnseenEncountered.add(msg.room);
|
||||
return false;
|
||||
}
|
||||
|
||||
return lastUnseenEncountered.has(msg.room);
|
||||
});
|
||||
|
||||
return unseenMessages.reduce((acc, cur) => {
|
||||
const count = acc.get(cur.room) ?? 0;
|
||||
acc.set(cur.room, count + 1);
|
||||
return acc;
|
||||
}, new Map<string, number>());
|
||||
}
|
||||
|
|
@ -3,17 +3,18 @@ import { Label } from "./Label";
|
|||
import * as React from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
const EDITABLE_CSS_VARS = [
|
||||
const CUSTOM_COLORS = [
|
||||
"bg",
|
||||
"bg-darker",
|
||||
"bg-lighter",
|
||||
"text",
|
||||
"text-lighter",
|
||||
"theme",
|
||||
"chat",
|
||||
] as const;
|
||||
|
||||
type CustomColorsRecord = Partial<
|
||||
Record<(typeof EDITABLE_CSS_VARS)[number], string>
|
||||
Record<(typeof CUSTOM_COLORS)[number], string>
|
||||
>;
|
||||
|
||||
export function CustomizedColorsInput({
|
||||
|
|
@ -31,7 +32,7 @@ export function CustomizedColorsInput({
|
|||
<Label>{t("custom.colors.title")}</Label>
|
||||
<input type="hidden" name="css" value={JSON.stringify(colors)} />
|
||||
<div className="colors__grid">
|
||||
{EDITABLE_CSS_VARS.map((cssVar) => {
|
||||
{CUSTOM_COLORS.map((cssVar) => {
|
||||
return (
|
||||
<React.Fragment key={cssVar}>
|
||||
<div>{t(`custom.colors.${cssVar}`)}</div>
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ export function TierImage({ tier, width = 200 }: TierImageProps) {
|
|||
const height = width * 0.8675;
|
||||
|
||||
return (
|
||||
<div className="tier__container">
|
||||
<div className="tier__container" style={{ width }}>
|
||||
<Image
|
||||
path={tierImageUrl(tier.name)}
|
||||
width={width}
|
||||
|
|
|
|||
56
app/components/NewTabs.tsx
Normal file
56
app/components/NewTabs.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Tab } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface NewTabsProps {
|
||||
tabs: {
|
||||
label: string;
|
||||
number?: number;
|
||||
hidden?: boolean;
|
||||
}[];
|
||||
content: {
|
||||
key: string;
|
||||
element: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
}[];
|
||||
scrolling?: boolean;
|
||||
selectedIndex?: number;
|
||||
setSelectedIndex?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function NewTabs({
|
||||
tabs,
|
||||
content,
|
||||
scrolling = true,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
}: NewTabsProps) {
|
||||
return (
|
||||
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
|
||||
<Tab.List
|
||||
className={clsx("tab__buttons-container", {
|
||||
"overflow-x-auto": scrolling,
|
||||
})}
|
||||
>
|
||||
{tabs
|
||||
.filter((t) => !t.hidden)
|
||||
.map((tab) => {
|
||||
return (
|
||||
<Tab key={tab.label} className="tab__button">
|
||||
{tab.label}
|
||||
{typeof tab.number === "number" && tab.number !== 0 && (
|
||||
<span className={clsx("tab__number")}>{tab.number}</span>
|
||||
)}
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-4">
|
||||
{content
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => {
|
||||
return <Tab.Panel key={c.key}>{c.element}</Tab.Panel>;
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
}
|
||||
25
app/components/icons/Chat.tsx
Normal file
25
app/components/icons/Chat.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { CSSProperties } from "react";
|
||||
|
||||
export function ChatIcon({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97-1.94.284-3.916.455-5.922.505a.39.39 0 00-.266.112L8.78 21.53A.75.75 0 017.5 21v-3.955a48.842 48.842 0 01-2.652-.316c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
app/components/icons/Clipboard.tsx
Normal file
18
app/components/icons/Clipboard.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export function ClipboardIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
app/components/icons/Link.tsx
Normal file
16
app/components/icons/Link.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export function LinkIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M19.902 4.098a3.75 3.75 0 00-5.304 0l-4.5 4.5a3.75 3.75 0 001.035 6.037.75.75 0 01-.646 1.353 5.25 5.25 0 01-1.449-8.45l4.5-4.5a5.25 5.25 0 117.424 7.424l-1.757 1.757a.75.75 0 11-1.06-1.06l1.757-1.757a3.75 3.75 0 000-5.304zm-7.389 4.267a.75.75 0 011-.353 5.25 5.25 0 011.449 8.45l-4.5 4.5a5.25 5.25 0 11-7.424-7.424l1.757-1.757a.75.75 0 111.06 1.06l-1.757 1.757a3.75 3.75 0 105.304 5.304l4.5-4.5a3.75 3.75 0 00-1.035-6.037.75.75 0 01-.354-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -1736,6 +1736,7 @@ function playedMatches() {
|
|||
alphaGroupId: groupAlpha,
|
||||
bravoGroupId: groupBravo,
|
||||
mapList: randomMapList(groupAlpha, groupBravo),
|
||||
addChatCode: false,
|
||||
});
|
||||
|
||||
// update match createdAt to the past
|
||||
|
|
|
|||
|
|
@ -526,6 +526,7 @@ export interface Group {
|
|||
| "PREFER_ALL_MODES"
|
||||
| "NO_PREFERENCE";
|
||||
inviteCode: string;
|
||||
chatCode: string | null;
|
||||
status: "PREPARING" | "ACTIVE" | "INACTIVE";
|
||||
}
|
||||
|
||||
|
|
@ -549,6 +550,7 @@ export interface GroupMatch {
|
|||
createdAt: number;
|
||||
reportedAt: number | null;
|
||||
reportedByUserId: number | null;
|
||||
chatCode: string | null;
|
||||
}
|
||||
|
||||
export interface GroupMatchMap {
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ const PERKS = [
|
|||
name: "discordColorRole",
|
||||
extraInfo: true,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "chatColor",
|
||||
extraInfo: false,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "seePlusPercentage",
|
||||
|
|
|
|||
|
|
@ -1,183 +1,118 @@
|
|||
import { Link, useFetcher } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { Flipped } from "react-flip-toolkit";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { TierImage, WeaponImage } from "~/components/Image";
|
||||
import { Image, TierImage, WeaponImage } from "~/components/Image";
|
||||
import { Popover } from "~/components/Popover";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { ArrowsPointingInIcon } from "~/components/icons/ArrowsPointingIn";
|
||||
import { StarFilledIcon } from "~/components/icons/StarFilled";
|
||||
import UndoIcon from "~/components/icons/Undo";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { MicrophoneIcon } from "~/components/icons/Microphone";
|
||||
import { SpeakerIcon } from "~/components/icons/Speaker";
|
||||
import { SpeakerXIcon } from "~/components/icons/SpeakerX";
|
||||
import type { Group, GroupMember as GroupMemberType } from "~/db/types";
|
||||
import { SENDOUQ_LOOKING_PAGE, TIERS_PAGE, userPage } from "~/utils/urls";
|
||||
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { useUser } from "~/modules/auth";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import { SENDOUQ_LOOKING_PAGE, navIconUrl, userPage } from "~/utils/urls";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import type { LookingGroup } from "../q-types";
|
||||
import { ModePreferenceIcons } from "./ModePrefenceIcons";
|
||||
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
|
||||
import { Popover } from "~/components/Popover";
|
||||
import { SpeakerIcon } from "~/components/icons/Speaker";
|
||||
import { MicrophoneIcon } from "~/components/icons/Microphone";
|
||||
import { SpeakerXIcon } from "~/components/icons/SpeakerX";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import { useUser } from "~/modules/auth";
|
||||
import { StarIcon } from "~/components/icons/Star";
|
||||
import { StarFilledIcon } from "~/components/icons/StarFilled";
|
||||
|
||||
export function GroupCard({
|
||||
group,
|
||||
action,
|
||||
mapListPreference,
|
||||
ownRole,
|
||||
ownGroup = false,
|
||||
isExpired = false,
|
||||
}: {
|
||||
group: LookingGroup;
|
||||
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP";
|
||||
mapListPreference?: Group["mapListPreference"];
|
||||
ownRole?: GroupMemberType["role"];
|
||||
ownGroup?: boolean;
|
||||
isExpired?: boolean;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<Flipped flipId={group.id}>
|
||||
<section className="q__group">
|
||||
{mapListPreference ? (
|
||||
<div className="stack lg horizontal justify-center">
|
||||
<div className="stack xs horizontal items-center">
|
||||
<ModePreferenceIcons preference={mapListPreference} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={clsx("stack sm", {
|
||||
"horizontal justify-center": !group.members,
|
||||
})}
|
||||
>
|
||||
{group.members?.map((member) => {
|
||||
return (
|
||||
<React.Fragment key={member.discordId}>
|
||||
<GroupMember
|
||||
member={member}
|
||||
showActions={ownGroup && ownRole === "OWNER"}
|
||||
/>
|
||||
<div className="stack md horizontal items-center justify-between">
|
||||
<div className="q__group-member-weapons">
|
||||
{member.weapons?.map((weapon) => {
|
||||
return (
|
||||
<WeaponImage
|
||||
key={weapon}
|
||||
weaponSplId={weapon}
|
||||
variant="badge"
|
||||
size={36}
|
||||
className="q__group-member-weapon"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{member.skill ? (
|
||||
<Popover
|
||||
buttonChildren={
|
||||
<div className="text-xs font-bold text-lighter stack horizontal xxs items-center">
|
||||
<TierImage tier={member.skill.tier} width={36} />
|
||||
{!member.skill.approximate ? (
|
||||
<>{ordinalToRoundedSp(member.skill.ordinal)}SP</>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="stack sm items-center">
|
||||
<TierImage tier={member.skill.tier} width={100} />
|
||||
<div>
|
||||
{member.skill.tier.name}
|
||||
{member.skill.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
<Link
|
||||
to={TIERS_PAGE}
|
||||
className="text-xs"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
See all tiers
|
||||
</Link>
|
||||
</div>
|
||||
</Popover>
|
||||
) : null}
|
||||
<section className="q__group">
|
||||
<div
|
||||
className={clsx("stack md", {
|
||||
"horizontal justify-center": !group.members,
|
||||
})}
|
||||
>
|
||||
{group.members?.map((member) => {
|
||||
return (
|
||||
<GroupMember
|
||||
member={member}
|
||||
showActions={ownGroup && ownRole === "OWNER"}
|
||||
key={member.discordId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{!group.members
|
||||
? new Array(FULL_GROUP_SIZE).fill(null).map((_, i) => {
|
||||
return (
|
||||
<div key={i} className="q__member-placeholder">
|
||||
?
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{!group.members
|
||||
? new Array(FULL_GROUP_SIZE).fill(null).map((_, i) => {
|
||||
return (
|
||||
<div key={i} className="q__member-placeholder">
|
||||
?
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
{group.tier ? (
|
||||
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
|
||||
<TierImage tier={group.tier} width={100} />
|
||||
<div>
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}{" "}
|
||||
{group.isReplay ? (
|
||||
<>
|
||||
/ <span className="text-theme-secondary">REPLAY</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
{group.tier ? (
|
||||
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
|
||||
<TierImage tier={group.tier} width={100} />
|
||||
<div>
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}{" "}
|
||||
{group.isReplay ? (
|
||||
<>
|
||||
/ <span className="text-theme-secondary">REPLAY</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{action && (ownRole === "OWNER" || ownRole === "MANAGER") ? (
|
||||
<fetcher.Form className="stack items-center" method="post">
|
||||
<input type="hidden" name="targetGroupId" value={group.id} />
|
||||
<SubmitButton
|
||||
size="tiny"
|
||||
variant={action === "UNLIKE" ? "destructive" : "outlined"}
|
||||
_action={action}
|
||||
state={fetcher.state}
|
||||
icon={
|
||||
action === "MATCH_UP" ? (
|
||||
<ArrowsPointingInIcon />
|
||||
) : action === "LIKE" ? (
|
||||
<StarFilledIcon />
|
||||
) : action === "GROUP_UP" ? (
|
||||
<UsersIcon />
|
||||
) : (
|
||||
<UndoIcon />
|
||||
)
|
||||
}
|
||||
>
|
||||
{action === "MATCH_UP"
|
||||
? "Start match"
|
||||
: action === "LIKE" && !group.members
|
||||
? "Challenge"
|
||||
: action === "LIKE"
|
||||
? "Ask to play"
|
||||
: action === "GROUP_UP"
|
||||
? "Group up"
|
||||
: "Undo"}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
{ownGroup ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading="Leave this group?"
|
||||
fields={[["_action", "LEAVE_GROUP"]]}
|
||||
deleteButtonText="Leave"
|
||||
action={SENDOUQ_LOOKING_PAGE}
|
||||
</div>
|
||||
) : null}
|
||||
{action &&
|
||||
(ownRole === "OWNER" || ownRole === "MANAGER") &&
|
||||
!isExpired ? (
|
||||
<fetcher.Form className="stack items-center" method="post">
|
||||
<input type="hidden" name="targetGroupId" value={group.id} />
|
||||
<SubmitButton
|
||||
size="tiny"
|
||||
variant={action === "UNLIKE" ? "destructive" : "outlined"}
|
||||
_action={action}
|
||||
state={fetcher.state}
|
||||
>
|
||||
<Button variant="minimal-destructive" size="tiny">
|
||||
Leave group
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
</section>
|
||||
</Flipped>
|
||||
{action === "MATCH_UP"
|
||||
? "Start match"
|
||||
: action === "LIKE" && !group.members
|
||||
? "Challenge"
|
||||
: action === "LIKE"
|
||||
? "Invite"
|
||||
: action === "GROUP_UP"
|
||||
? "Group up"
|
||||
: "Undo"}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
{ownGroup ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading="Leave this group?"
|
||||
fields={[["_action", "LEAVE_GROUP"]]}
|
||||
deleteButtonText="Leave"
|
||||
action={SENDOUQ_LOOKING_PAGE}
|
||||
>
|
||||
<Button variant="minimal-destructive" size="tiny">
|
||||
Leave group
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -187,45 +122,123 @@ function GroupMember({
|
|||
}: {
|
||||
member: NonNullable<LookingGroup["members"]>[number];
|
||||
showActions: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack xxs">
|
||||
<div className="q__group-member">
|
||||
<Link
|
||||
to={userPage(member)}
|
||||
className="text-main-forced stack xs horizontal items-center"
|
||||
target="_blank"
|
||||
>
|
||||
<Avatar user={member} size="xs" />
|
||||
<span className="q__group-member__name">{member.discordName}</span>
|
||||
</Link>
|
||||
<div className="ml-auto stack horizontal sm items-center">
|
||||
{showActions ? <MemberRoleManager member={member} /> : null}
|
||||
{member.skill ? <TierInfo skill={member.skill} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<div className="stack horizontal xxs">
|
||||
{member.vc ? (
|
||||
<div className="q__group-member__extra-info">
|
||||
<VoiceChatInfo member={member} />
|
||||
</div>
|
||||
) : null}
|
||||
{member.plusTier ? (
|
||||
<div className="q__group-member__extra-info">
|
||||
<Image path={navIconUrl("plus")} width={20} height={20} alt="" />
|
||||
{member.plusTier}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{member.weapons ? (
|
||||
<div className="q__group-member__extra-info">
|
||||
{member.weapons?.map((weapon) => {
|
||||
return (
|
||||
<WeaponImage
|
||||
key={weapon}
|
||||
weaponSplId={weapon}
|
||||
variant="badge"
|
||||
size={26}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRoleManager({
|
||||
member,
|
||||
}: {
|
||||
member: NonNullable<LookingGroup["members"]>[number];
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["q"]);
|
||||
const Icon = member.role === "OWNER" ? StarFilledIcon : StarIcon;
|
||||
|
||||
return (
|
||||
<fetcher.Form
|
||||
className="stack sm horizontal items-center font-bold"
|
||||
method="post"
|
||||
action={SENDOUQ_LOOKING_PAGE}
|
||||
<Popover
|
||||
buttonChildren={
|
||||
<Icon
|
||||
className={clsx("q__group-member__star", {
|
||||
"q__group-member__star__inactive": member.role === "REGULAR",
|
||||
})}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<input type="hidden" name="userId" value={member.id} />
|
||||
<Link to={userPage(member)} className="q__group-member" target="_blank">
|
||||
<Avatar user={member} size="xxs" />
|
||||
{member.discordName}
|
||||
</Link>
|
||||
{member.plusTier ? (
|
||||
<div className="text-xs text-lighter">+{member.plusTier}</div>
|
||||
) : null}
|
||||
{member.role === "REGULAR" && showActions ? (
|
||||
<SubmitButton
|
||||
variant="minimal"
|
||||
size="tiny"
|
||||
_action="GIVE_MANAGER"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Give manager
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
{member.role === "MANAGER" && showActions ? (
|
||||
<SubmitButton
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
_action="REMOVE_MANAGER"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Remove manager
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
<VoiceChatInfo member={member} />
|
||||
</fetcher.Form>
|
||||
<div className="stack md items-center">
|
||||
<div>{t(`q:roles.${member.role}`)}</div>
|
||||
{member.role !== "OWNER" ? (
|
||||
<fetcher.Form method="post" action={SENDOUQ_LOOKING_PAGE}>
|
||||
<input type="hidden" name="userId" value={member.id} />
|
||||
{member.role === "REGULAR" ? (
|
||||
<SubmitButton
|
||||
variant="minimal"
|
||||
size="tiny"
|
||||
_action="GIVE_MANAGER"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Give manager
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
{member.role === "MANAGER" ? (
|
||||
<SubmitButton
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
_action="REMOVE_MANAGER"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Remove manager
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function TierInfo({ skill }: { skill: TieredSkill }) {
|
||||
return (
|
||||
<div className="q__group-member__tier">
|
||||
<Popover buttonChildren={<TierImage tier={skill.tier} width={38} />}>
|
||||
<div className="stack sm items-center">
|
||||
<TierImage tier={skill.tier} width={80} />
|
||||
<div className="text-lighter text-xxs">
|
||||
{skill.tier.name}
|
||||
{skill.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
{!skill.approximate ? (
|
||||
<> {ordinalToRoundedSp(skill.ordinal)}SP</>
|
||||
) : null}
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import {
|
|||
sendouQInviteLink,
|
||||
} from "~/utils/urls";
|
||||
import * as React from "react";
|
||||
import { ClipboardIcon } from "~/components/icons/Clipboard";
|
||||
import { PlusIcon } from "~/components/icons/Plus";
|
||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||
|
||||
export function MemberAdder({
|
||||
inviteCode,
|
||||
|
|
@ -22,18 +25,28 @@ export function MemberAdder({
|
|||
const [trustedUser, setTrustedUser] = React.useState<number>();
|
||||
const fetcher = useFetcher();
|
||||
const inviteLink = `${SENDOU_INK_BASE_URL}${sendouQInviteLink(inviteCode)}`;
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const [state, copyToClipboard] = useCopyToClipboard();
|
||||
const [copySuccess, setCopySuccess] = React.useState(false);
|
||||
|
||||
const trustedPlayerIdsJoined = trustedPlayers.map((p) => p.id).join(",");
|
||||
React.useEffect(() => {
|
||||
setTrustedUser(undefined);
|
||||
}, [trustedPlayerIdsJoined]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!state.value) return;
|
||||
|
||||
setCopySuccess(true);
|
||||
const timeout = setTimeout(() => setCopySuccess(false), 2000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<div className="stack horizontal lg flex-wrap justify-center">
|
||||
<div className="stack md flex-wrap justify-center">
|
||||
{trustedPlayers.length > 0 ? (
|
||||
<fetcher.Form method="post" action={SENDOUQ_PREPARING_PAGE}>
|
||||
<label htmlFor="players">Add people you have played with</label>
|
||||
<label htmlFor="players">Quick add</label>
|
||||
<div className="stack horizontal sm items-center">
|
||||
<select
|
||||
name="id"
|
||||
|
|
@ -43,8 +56,9 @@ export function MemberAdder({
|
|||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
className="q__member-adder__input"
|
||||
>
|
||||
<option value="">Select member</option>
|
||||
<option value="">Select user</option>
|
||||
{trustedPlayers.map((player) => {
|
||||
return (
|
||||
<option key={player.id} value={player.id}>
|
||||
|
|
@ -57,22 +71,27 @@ export function MemberAdder({
|
|||
variant="outlined"
|
||||
_action="ADD_TRUSTED"
|
||||
disabled={!trustedUser}
|
||||
>
|
||||
Add
|
||||
</SubmitButton>
|
||||
icon={<PlusIcon />}
|
||||
/>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
<div>
|
||||
<label htmlFor="invite">Share your invite link</label>
|
||||
<label htmlFor="invite">Invite link</label>
|
||||
<div className="stack horizontal sm items-center">
|
||||
<input type="text" value={inviteLink} readOnly id="invite" />
|
||||
<input
|
||||
type="text"
|
||||
value={inviteLink}
|
||||
readOnly
|
||||
id="invite"
|
||||
className="q__member-adder__input"
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
variant={copySuccess ? "outlined-success" : "outlined"}
|
||||
onClick={() => copyToClipboard(inviteLink)}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
|
||||
aria-label="Copy to clipboard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export function divideGroups({
|
|||
let own: LookingGroupWithInviteCode | null = null;
|
||||
const neutral: LookingGroupWithInviteCode[] = [];
|
||||
const likesReceived: LookingGroupWithInviteCode[] = [];
|
||||
const likesGiven: LookingGroupWithInviteCode[] = [];
|
||||
|
||||
const unneutralGroupIds = new Set<number>();
|
||||
for (const like of likes) {
|
||||
|
|
@ -45,9 +44,7 @@ export function divideGroups({
|
|||
break;
|
||||
}
|
||||
if (like.targetGroupId === group.id) {
|
||||
likesGiven.push(group);
|
||||
unneutralGroupIds.add(group.id);
|
||||
break;
|
||||
group.isLiked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -68,7 +65,6 @@ export function divideGroups({
|
|||
return {
|
||||
own,
|
||||
neutral,
|
||||
likesGiven,
|
||||
likesReceived,
|
||||
};
|
||||
}
|
||||
|
|
@ -132,7 +128,6 @@ export function addReplayIndicator({
|
|||
|
||||
return {
|
||||
own: groups.own,
|
||||
likesGiven: groups.likesGiven.map(addReplayIndicatorIfNeeded),
|
||||
likesReceived: groups.likesReceived.map(addReplayIndicatorIfNeeded),
|
||||
neutral: groups.neutral.map(addReplayIndicatorIfNeeded),
|
||||
};
|
||||
|
|
@ -164,9 +159,6 @@ export function censorGroups({
|
|||
neutral: groups.neutral.map(
|
||||
showMembers ? censorGroupPartly : censorGroupFully,
|
||||
),
|
||||
likesGiven: groups.likesGiven.map(
|
||||
showMembers ? censorGroupPartly : censorGroupFully,
|
||||
),
|
||||
likesReceived: groups.likesReceived.map(
|
||||
showMembers ? censorGroupPartly : censorGroupFully,
|
||||
),
|
||||
|
|
@ -214,7 +206,6 @@ export function addSkillsToGroups({
|
|||
return {
|
||||
own: addSkill(groups.own),
|
||||
neutral: groups.neutral.map(addSkill),
|
||||
likesGiven: groups.likesGiven.map(addSkill),
|
||||
likesReceived: groups.likesReceived.map(addSkill),
|
||||
};
|
||||
}
|
||||
|
|
@ -244,3 +235,12 @@ export function groupExpiryStatus(
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasAccessToChat(isByAdmin: boolean) {
|
||||
const ff = process.env["FF_ENABLE_CHAT"];
|
||||
|
||||
if (ff === "true") return true;
|
||||
if (ff === "admin") return isByAdmin;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,20 @@ export type LookingGroup = {
|
|||
mapListPreference?: Group["mapListPreference"];
|
||||
tier?: TieredSkill["tier"];
|
||||
isReplay?: boolean;
|
||||
isLiked?: boolean;
|
||||
members?: {
|
||||
id: number;
|
||||
discordId: string;
|
||||
discordName: string;
|
||||
discordAvatar: string;
|
||||
customUrl?: User["customUrl"];
|
||||
plusTier?: PlusTier["tier"];
|
||||
role: GroupMember["role"];
|
||||
weapons?: MainWeaponId[];
|
||||
skill?: TieredSkill;
|
||||
vc?: User["vc"];
|
||||
languages?: string[];
|
||||
chatNameColor: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
|
|
@ -30,12 +33,10 @@ export interface DividedGroups {
|
|||
own: LookingGroup | LookingGroupWithInviteCode;
|
||||
neutral: LookingGroup[];
|
||||
likesReceived: LookingGroup[];
|
||||
likesGiven: LookingGroup[];
|
||||
}
|
||||
|
||||
export interface DividedGroupsUncensored {
|
||||
own: LookingGroupWithInviteCode;
|
||||
neutral: LookingGroupWithInviteCode[];
|
||||
likesReceived: LookingGroupWithInviteCode[];
|
||||
likesGiven: LookingGroupWithInviteCode[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,23 +52,77 @@
|
|||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.q__own-group-container {
|
||||
margin: 0 auto;
|
||||
min-width: 300px;
|
||||
.q__tab-button {
|
||||
width: 100px;
|
||||
background-color: var(--bg-lighter-solid);
|
||||
color: var(--text);
|
||||
font-size: var(--fonts-sm);
|
||||
border-color: transparent;
|
||||
gap: var(--s-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.q__tab-button__icon {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.q__tab-button__badge {
|
||||
font-size: var(--fonts-xxs);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: var(--theme);
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -1px;
|
||||
border-radius: 100%;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: var(--button-text);
|
||||
}
|
||||
|
||||
.q__top-container {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.q__top-container__divider {
|
||||
min-width: 3px;
|
||||
background-color: var(--border);
|
||||
height: 100%;
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.q__chat-container {
|
||||
top: 60px;
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.q__chat-messages-container {
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.q__groups-container {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--s-6);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--s-7);
|
||||
}
|
||||
|
||||
.q__mobile-groups-container {
|
||||
max-width: 300px;
|
||||
.q__groups-container__mobile {
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.q__groups-inner-container {
|
||||
max-width: 94vw;
|
||||
}
|
||||
|
||||
.q__groups-container__right {
|
||||
margin-top: 45px;
|
||||
}
|
||||
|
||||
.q__group {
|
||||
background-color: var(--bg-lighter-solid);
|
||||
width: 100%;
|
||||
|
|
@ -83,9 +137,33 @@
|
|||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
font-size: var(--fonts-xs);
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--rounded);
|
||||
font-size: var(--fonts-xsm);
|
||||
font-weight: var(--semi-bold);
|
||||
padding-inline-end: var(--s-2-5);
|
||||
}
|
||||
|
||||
.q__group-member__name {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.q__group-member__tier {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.q__group-member__extra-info {
|
||||
font-size: var(--fonts-xs);
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-0-5) var(--s-2);
|
||||
width: max-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.q__member-placeholder {
|
||||
|
|
@ -112,10 +190,26 @@
|
|||
}
|
||||
|
||||
.q__group-member-vc-icon {
|
||||
width: 18px;
|
||||
height: 15px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.q__group-member__star {
|
||||
min-width: 18px;
|
||||
max-width: 18px;
|
||||
color: var(--theme-secondary);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.q__group-member__star__inactive {
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.q__member-adder__input {
|
||||
--input-width: 11rem;
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
.q-preparing__card-container {
|
||||
min-width: 250px;
|
||||
margin: 0 auto;
|
||||
|
|
@ -129,6 +223,13 @@
|
|||
margin-block-start: var(--s-1);
|
||||
}
|
||||
|
||||
.q-match__join-discord-section__highlighted {
|
||||
font-size: var(--fonts-md);
|
||||
letter-spacing: 1px;
|
||||
color: var(--text);
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.q-match__members-container {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
|
@ -163,7 +264,11 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 480px) {
|
||||
@media screen and (min-width: 640px) {
|
||||
.q-match__teams-container.with-chat {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.q-match__teams-container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ import { INVITE_CODE_LENGTH } from "~/constants";
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Group, GroupMember } from "~/db/types";
|
||||
import type { MapPool } from "~/modules/map-pool-serializer";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { hasAccessToChat } from "../core/groups.server";
|
||||
|
||||
const createGroupStm = sql.prepare(/* sql */ `
|
||||
insert into "Group"
|
||||
("mapListPreference", "inviteCode", "status")
|
||||
("mapListPreference", "inviteCode", "status", "chatCode")
|
||||
values
|
||||
(@mapListPreference, @inviteCode, @status)
|
||||
(@mapListPreference, @inviteCode, @status, @chatCode)
|
||||
returning *
|
||||
`);
|
||||
|
||||
|
|
@ -39,6 +41,9 @@ export const createGroup = sql.transaction((args: CreateGroupArgs) => {
|
|||
mapListPreference: args.mapListPreference,
|
||||
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
||||
status: args.status,
|
||||
chatCode: hasAccessToChat(isAdmin({ id: args.userId }))
|
||||
? nanoid(INVITE_CODE_LENGTH)
|
||||
: null,
|
||||
}) as Group;
|
||||
|
||||
createGroupMemberStm.run({
|
||||
|
|
@ -68,11 +73,12 @@ type CreateGroupFromPreviousGroupArgs = {
|
|||
|
||||
const createGroupFromPreviousGroupStm = sql.prepare(/* sql */ `
|
||||
insert into "Group"
|
||||
("mapListPreference", "teamId", "inviteCode", "status")
|
||||
("mapListPreference", "teamId", "chatCode", "inviteCode", "status")
|
||||
values
|
||||
(
|
||||
(select "mapListPreference" from "Group" where "id" = @previousGroupId),
|
||||
(select "teamId" from "Group" where "id" = @previousGroupId),
|
||||
(select "chatCode" from "Group" where "id" = @previousGroupId),
|
||||
@inviteCode,
|
||||
@status
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { nanoid } from "nanoid";
|
||||
import { sql } from "~/db/sql";
|
||||
import type { GroupMatch } from "~/db/types";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
|
|
@ -6,10 +7,12 @@ import { syncGroupTeamId } from "./syncGroupTeamId.server";
|
|||
const createMatchStm = sql.prepare(/* sql */ `
|
||||
insert into "GroupMatch" (
|
||||
"alphaGroupId",
|
||||
"bravoGroupId"
|
||||
"bravoGroupId",
|
||||
"chatCode"
|
||||
) values (
|
||||
@alphaGroupId,
|
||||
@bravoGroupId
|
||||
@bravoGroupId,
|
||||
@chatCode
|
||||
)
|
||||
returning *
|
||||
`);
|
||||
|
|
@ -35,14 +38,17 @@ export const createMatch = sql.transaction(
|
|||
alphaGroupId,
|
||||
bravoGroupId,
|
||||
mapList,
|
||||
addChatCode,
|
||||
}: {
|
||||
alphaGroupId: number;
|
||||
bravoGroupId: number;
|
||||
addChatCode: boolean;
|
||||
mapList: TournamentMapListMap[];
|
||||
}) => {
|
||||
const match = createMatchStm.get({
|
||||
alphaGroupId,
|
||||
bravoGroupId,
|
||||
chatCode: addChatCode ? nanoid(10) : null,
|
||||
}) as GroupMatch;
|
||||
|
||||
for (const [i, { mode, source, stageId }] of mapList.entries()) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
"Group"."id",
|
||||
"Group"."status",
|
||||
"Group"."latestActionAt",
|
||||
"Group"."chatCode",
|
||||
"GroupMatch"."id" as "matchId",
|
||||
"GroupMember"."role"
|
||||
from
|
||||
|
|
@ -19,7 +20,10 @@ const stm = sql.prepare(/* sql */ `
|
|||
and "GroupMember"."userId" = @userId
|
||||
`);
|
||||
|
||||
type ActiveGroup = Pick<Group, "id" | "status" | "latestActionAt"> & {
|
||||
type ActiveGroup = Pick<
|
||||
Group,
|
||||
"id" | "status" | "latestActionAt" | "chatCode"
|
||||
> & {
|
||||
matchId?: number;
|
||||
role: GroupMember["role"];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
"GroupMatch"."createdAt",
|
||||
"GroupMatch"."reportedAt",
|
||||
"GroupMatch"."reportedByUserId",
|
||||
"GroupMatch"."chatCode",
|
||||
(select exists (select 1 from "Skill" where "Skill"."groupMatchId" = @id)) as "isLocked",
|
||||
json_group_array(
|
||||
json_object(
|
||||
|
|
@ -34,6 +35,7 @@ export interface MatchById {
|
|||
createdAt: GroupMatch["createdAt"];
|
||||
reportedAt: GroupMatch["reportedAt"];
|
||||
reportedByUserId: GroupMatch["reportedByUserId"];
|
||||
chatCode: GroupMatch["chatCode"];
|
||||
isLocked: number;
|
||||
mapList: Array<
|
||||
Pick<GroupMatchMap, "id" | "mode" | "stageId" | "source" | "winnerGroupId">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
)
|
||||
select
|
||||
"Group"."id",
|
||||
"Group"."chatCode",
|
||||
"AllTeam"."name" as "teamName",
|
||||
"AllTeam"."customUrl" as "teamCustomUrl",
|
||||
"UserSubmittedImage"."url" as "teamAvatarUrl",
|
||||
|
|
@ -29,7 +30,8 @@ const stm = sql.prepare(/* sql */ `
|
|||
'role', "GroupMemberWithWeapon"."role",
|
||||
'customUrl', "User"."customUrl",
|
||||
'inGameName', "User"."inGameName",
|
||||
'weapons', "GroupMemberWithWeapon"."weapons"
|
||||
'weapons', "GroupMemberWithWeapon"."weapons",
|
||||
'chatNameColor', IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)
|
||||
)
|
||||
) as "members"
|
||||
from
|
||||
|
|
@ -46,6 +48,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
|
||||
export interface GroupForMatch {
|
||||
id: Group["id"];
|
||||
chatCode: Group["chatCode"];
|
||||
team?: {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
|
|
@ -60,6 +63,7 @@ export interface GroupForMatch {
|
|||
customUrl: User["customUrl"];
|
||||
inGameName: User["inGameName"];
|
||||
weapons: Array<MainWeaponId>;
|
||||
chatNameColor: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -69,6 +73,7 @@ export function groupForMatch(id: number) {
|
|||
|
||||
return {
|
||||
id: row.id,
|
||||
chatCode: row.chatCode,
|
||||
team: row.teamName
|
||||
? {
|
||||
name: row.teamName,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ const stm = sql.prepare(/* sql */ `
|
|||
"User"."discordId",
|
||||
"User"."discordName",
|
||||
"User"."discordAvatar",
|
||||
"User"."customUrl",
|
||||
"User"."vc",
|
||||
IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null) as "chatNameColor",
|
||||
"User"."languages",
|
||||
"PlusTier"."tier" as "plusTier",
|
||||
"GroupMember"."role",
|
||||
|
|
@ -49,6 +51,8 @@ const stm = sql.prepare(/* sql */ `
|
|||
'discordId', "q1"."discordId",
|
||||
'discordName', "q1"."discordName",
|
||||
'discordAvatar', "q1"."discordAvatar",
|
||||
'chatNameColor', "q1"."chatNameColor",
|
||||
'customUrl', "q1"."customUrl",
|
||||
'plusTier', "q1"."plusTier",
|
||||
'role', "q1"."role",
|
||||
'weapons', "q1"."weapons",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { nanoid } from "nanoid";
|
||||
import { sql } from "~/db/sql";
|
||||
import { deleteLikesByGroupId } from "./deleteLikesByGroupId.server";
|
||||
|
||||
|
|
@ -16,21 +17,37 @@ const addGroupMemberStm = sql.prepare(/* sql */ `
|
|||
values (@groupId, @userId, @role)
|
||||
`);
|
||||
|
||||
const updateGroupStm = sql.prepare(/* sql */ `
|
||||
update "Group"
|
||||
set "chatCode" = @chatCode
|
||||
where "id" = @groupId
|
||||
`);
|
||||
|
||||
export const morphGroups = sql.transaction(
|
||||
({
|
||||
survivingGroupId,
|
||||
otherGroupId,
|
||||
newMembers,
|
||||
addChatCode,
|
||||
}: {
|
||||
survivingGroupId: number;
|
||||
otherGroupId: number;
|
||||
newMembers: number[];
|
||||
addChatCode: boolean;
|
||||
}) => {
|
||||
deleteGroupStm.run({ groupId: otherGroupId });
|
||||
deleteGroupMapsStm.run({ groupId: otherGroupId });
|
||||
|
||||
deleteLikesByGroupId(survivingGroupId);
|
||||
|
||||
// reset chat code so previous messages are not visible
|
||||
if (addChatCode) {
|
||||
updateGroupStm.run({
|
||||
groupId: survivingGroupId,
|
||||
chatCode: nanoid(10),
|
||||
});
|
||||
}
|
||||
|
||||
for (const userId of newMembers) {
|
||||
addGroupMemberStm.run({
|
||||
groupId: survivingGroupId,
|
||||
|
|
|
|||
|
|
@ -8,13 +8,12 @@ import { redirect } from "@remix-run/node";
|
|||
import { useFetcher, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { Flipper } from "react-flip-toolkit";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { getUserId, requireUserId } from "~/modules/auth/user.server";
|
||||
import { getUser, requireUserId } from "~/modules/auth/user.server";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
import {
|
||||
parseRequestFormData,
|
||||
|
|
@ -37,6 +36,7 @@ import {
|
|||
divideGroups,
|
||||
filterOutGroupsWithIncompatibleMapListPreference,
|
||||
groupExpiryStatus,
|
||||
hasAccessToChat,
|
||||
membersNeededForFull,
|
||||
} from "../core/groups.server";
|
||||
import { matchMapList } from "../core/match.server";
|
||||
|
|
@ -64,12 +64,14 @@ import { MemberAdder } from "../components/MemberAdder";
|
|||
import type { LookingGroupWithInviteCode } from "../q-types";
|
||||
import { trustedPlayersAvailableToPlay } from "../queries/usersInActiveGroup.server";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import { Tab, Tabs } from "~/components/Tabs";
|
||||
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
|
||||
import { groupHasMatch } from "../queries/groupHasMatch.server";
|
||||
import { findRecentMatchPlayersByUserId } from "../queries/findRecentMatchPlayersByUserId.server";
|
||||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import { Chat, useChat } from "~/components/Chat";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { NewTabs } from "~/components/NewTabs";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
|
|
@ -170,6 +172,9 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
survivingGroupId,
|
||||
otherGroupId: otherGroup.id,
|
||||
newMembers: otherGroup.members.map((m) => m.id),
|
||||
addChatCode: hasAccessToChat(
|
||||
ourGroup.members.some(isAdmin) || theirGroup.members.some(isAdmin),
|
||||
),
|
||||
});
|
||||
refreshGroup(survivingGroupId);
|
||||
|
||||
|
|
@ -218,6 +223,9 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
const createdMatch = createMatch({
|
||||
alphaGroupId: ourGroup.id,
|
||||
bravoGroupId: theirGroup.id,
|
||||
addChatCode: hasAccessToChat(
|
||||
ourGroup.members.some(isAdmin) || theirGroup.members.some(isAdmin),
|
||||
),
|
||||
mapList: matchMapList({
|
||||
ourGroup,
|
||||
theirGroup,
|
||||
|
|
@ -280,7 +288,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const user = await getUser(request);
|
||||
|
||||
const currentGroup = user ? findCurrentGroupByUserId(user.id) : undefined;
|
||||
const redirectLocation = groupRedirectLocationByCurrentLocation({
|
||||
|
|
@ -337,6 +345,9 @@ export const loader = async ({ request }: LoaderArgs) => {
|
|||
return {
|
||||
groups: censoredGroups,
|
||||
role: currentGroup.role,
|
||||
chatCode:
|
||||
// don't chat with yourself...
|
||||
censoredGroups.own.members!.length > 1 ? currentGroup.chatCode : null,
|
||||
lastUpdated: new Date().getTime(),
|
||||
expiryStatus: groupExpiryStatus(currentGroup),
|
||||
trustedPlayers: hasGroupManagerPerms(currentGroup.role)
|
||||
|
|
@ -346,33 +357,14 @@ export const loader = async ({ request }: LoaderArgs) => {
|
|||
};
|
||||
|
||||
export default function QLookingPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams] = useSearchParams();
|
||||
useAutoRefresh();
|
||||
|
||||
const ownGroup = data.groups.own as LookingGroupWithInviteCode;
|
||||
|
||||
const wasTryingToJoinAnotherTeam = searchParams.get("joining") === "true";
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<div className="stack sm">
|
||||
<InfoText />
|
||||
<div className="q__own-group-container">
|
||||
<GroupCard
|
||||
group={data.groups.own}
|
||||
mapListPreference={data.groups.own.mapListPreference}
|
||||
ownRole={data.role}
|
||||
ownGroup
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{ownGroup.inviteCode ? (
|
||||
<MemberAdder
|
||||
inviteCode={ownGroup.inviteCode}
|
||||
trustedPlayers={data.trustedPlayers}
|
||||
/>
|
||||
) : null}
|
||||
<Main className="stack md">
|
||||
<InfoText />
|
||||
{wasTryingToJoinAnotherTeam ? (
|
||||
<div className="text-warning text-center">
|
||||
Before joining another group, leave the current one
|
||||
|
|
@ -414,7 +406,7 @@ function InfoText() {
|
|||
method="post"
|
||||
className="text-xs text-lighter ml-auto text-warning stack horizontal sm"
|
||||
>
|
||||
Group will be hidden soon due to inactivity. Still looking?{" "}
|
||||
Group will be marked inactive. Still looking?{" "}
|
||||
<SubmitButton
|
||||
size="tiny"
|
||||
variant="minimal"
|
||||
|
|
@ -445,159 +437,220 @@ function InfoText() {
|
|||
function Groups() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [_unseenMessages, setUnseenMessages] = React.useState(0);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(1);
|
||||
const { width } = useWindowSize();
|
||||
|
||||
if (data.expiryStatus === "EXPIRED" || !isMounted) return null;
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(data.groups.own.members!.map((m) => [m.id, m]));
|
||||
}, [data]);
|
||||
|
||||
if (width < 750) return <MobileGroupCards />;
|
||||
return <GroupCardColumns />;
|
||||
}
|
||||
const rooms = React.useMemo(() => {
|
||||
return data.chatCode
|
||||
? [
|
||||
{
|
||||
code: data.chatCode,
|
||||
label: "Group",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}, [data.chatCode]);
|
||||
|
||||
function MobileGroupCards() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [tab, setTab] = React.useState<"received" | "neutral" | "given">(
|
||||
"neutral",
|
||||
const onNewMessage = React.useCallback(() => {
|
||||
setUnseenMessages((msg) => msg + 1);
|
||||
}, []);
|
||||
|
||||
const chat = useChat({ rooms, onNewMessage });
|
||||
|
||||
// reset to own group tab when the roster changes
|
||||
const memberIdsJoined = data.groups.own.members
|
||||
?.map((m) => m.id)
|
||||
.sort((a, b) => a - b)
|
||||
.join(",");
|
||||
React.useEffect(() => {
|
||||
if (memberIdsJoined && memberIdsJoined.split(",").length === 1) return;
|
||||
setSelectedIndex(0);
|
||||
}, [memberIdsJoined]);
|
||||
|
||||
const CHAT_TAB_INDEX = 3;
|
||||
// the way of doing it seems a bit backwards but trying to work
|
||||
// around React dep arrays triggering unintended effects from
|
||||
// running in the Chat component
|
||||
const handleTabChange = React.useCallback(
|
||||
(newIndex: number) => {
|
||||
const currentTabIndex = selectedIndex;
|
||||
if (currentTabIndex === CHAT_TAB_INDEX) {
|
||||
setUnseenMessages(0);
|
||||
}
|
||||
|
||||
setSelectedIndex(newIndex);
|
||||
},
|
||||
[selectedIndex],
|
||||
);
|
||||
const unseenMessages = selectedIndex !== CHAT_TAB_INDEX ? _unseenMessages : 0;
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
const isMobile = width < 750;
|
||||
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
|
||||
const ownGroup = data.groups.own as LookingGroupWithInviteCode;
|
||||
|
||||
const chatElement = (
|
||||
<div>
|
||||
{data.chatCode ? (
|
||||
<Chat
|
||||
rooms={rooms}
|
||||
users={chatUsers}
|
||||
className="w-full q__chat-container"
|
||||
messagesContainerClassName="q__chat-messages-container"
|
||||
onNewMessage={onNewMessage}
|
||||
chat={chat}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
|
||||
|
||||
const groups =
|
||||
tab === "received"
|
||||
? data.groups.likesReceived
|
||||
: tab === "given"
|
||||
? data.groups.likesGiven
|
||||
: data.groups.neutral;
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Tabs compact>
|
||||
<Tab active={tab === "received"} onClick={() => setTab("received")}>
|
||||
Received ({data.groups.likesReceived.length})
|
||||
</Tab>
|
||||
<Tab active={tab === "neutral"} onClick={() => setTab("neutral")}>
|
||||
Neutral ({data.groups.neutral.length})
|
||||
</Tab>
|
||||
<Tab active={tab === "given"} onClick={() => setTab("given")}>
|
||||
Given ({data.groups.likesGiven.length})
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<div className="stack sm q__mobile-groups-container">
|
||||
{groups.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: tab === "received" ? "THEM" : "US",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
<div
|
||||
className={clsx("q__groups-container", {
|
||||
"q__groups-container__mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
{!isMobile ? chatElement : null}
|
||||
<div className="q__groups-inner-container">
|
||||
<NewTabs
|
||||
scrolling={isMobile}
|
||||
selectedIndex={selectedIndex}
|
||||
setSelectedIndex={handleTabChange}
|
||||
tabs={[
|
||||
{
|
||||
label: "Roster",
|
||||
number: data.groups.own.members!.length,
|
||||
},
|
||||
{
|
||||
label: "Groups",
|
||||
number: data.groups.neutral.length,
|
||||
},
|
||||
{
|
||||
label: isFullGroup ? "Challenges" : "Invitations",
|
||||
number: data.groups.likesReceived.length,
|
||||
hidden: !isMobile,
|
||||
},
|
||||
{
|
||||
label: "Chat",
|
||||
hidden: !isMobile || !data.chatCode,
|
||||
number: unseenMessages,
|
||||
},
|
||||
// {
|
||||
// label: "Filter",
|
||||
// },
|
||||
]}
|
||||
content={[
|
||||
{
|
||||
key: "own",
|
||||
element: (
|
||||
<div className="stack md">
|
||||
<GroupCard
|
||||
group={data.groups.own}
|
||||
mapListPreference={data.groups.own.mapListPreference}
|
||||
ownRole={data.role}
|
||||
ownGroup
|
||||
/>
|
||||
{ownGroup.inviteCode ? (
|
||||
<MemberAdder
|
||||
inviteCode={ownGroup.inviteCode}
|
||||
trustedPlayers={data.trustedPlayers}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "groups",
|
||||
element: (
|
||||
<div className="stack sm">
|
||||
{data.groups.neutral.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: "US",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
|
||||
const action =
|
||||
tab === "neutral"
|
||||
? "LIKE"
|
||||
: tab === "given"
|
||||
? "UNLIKE"
|
||||
: isFullGroup
|
||||
? "MATCH_UP"
|
||||
: "GROUP_UP";
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={group.isLiked ? "UNLIKE" : "LIKE"}
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "received",
|
||||
hidden: !isMobile,
|
||||
element: (
|
||||
<div className="stack sm">
|
||||
{data.groups.likesReceived.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: "THEM",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={action}
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
element: chatElement,
|
||||
hidden: !isMobile || !data.chatCode,
|
||||
},
|
||||
// {
|
||||
// key: "filters",
|
||||
// element: <div>filters</div>,
|
||||
// },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{!isMobile ? (
|
||||
<div className="stack sm q__groups-container__right">
|
||||
{data.groups.likesReceived.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: "THEM",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupCardColumns() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const isFullGroup = data.groups.own.members!.length === FULL_GROUP_SIZE;
|
||||
|
||||
return (
|
||||
<Flipper
|
||||
flipKey={`${data.groups.likesReceived
|
||||
.map((g) => g.id)
|
||||
.join("")}-${data.groups.neutral
|
||||
.map((g) => g.id)
|
||||
.join("")}-${data.groups.likesGiven.map((g) => g.id).join("")}`}
|
||||
>
|
||||
<div className="q__groups-container">
|
||||
<div>
|
||||
<h2 className="text-sm text-center mb-2">
|
||||
{isFullGroup ? "Challenges received" : "Groups that asked you"}
|
||||
</h2>
|
||||
<div className="stack sm">
|
||||
{data.groups.likesReceived.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: "THEM",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<h2 className="text-sm text-center mb-2 invisible">Neutral</h2>
|
||||
<div className="stack sm">
|
||||
{data.groups.neutral.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: "US",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action="LIKE"
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm text-center mb-2">
|
||||
{isFullGroup ? "Challenges issued" : "Groups you asked"}
|
||||
</h2>
|
||||
<div className="stack sm">
|
||||
{data.groups.likesGiven.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: "US",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action="UNLIKE"
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Flipper>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import type { GroupMember, ReportedWeapon } from "~/db/types";
|
|||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { useUser } from "~/modules/auth";
|
||||
import { requireUserId } from "~/modules/auth/user.server";
|
||||
import { getUserId, requireUserId } from "~/modules/auth/user.server";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
|
@ -79,6 +79,7 @@ import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracke
|
|||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { addDummySkill } from "../queries/addDummySkill.server";
|
||||
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
|
||||
import { ConnectedChat, type ChatProps } from "~/components/Chat";
|
||||
import { currentSeason } from "~/features/mmr";
|
||||
import { StarFilledIcon } from "~/components/icons/StarFilled";
|
||||
import { StarIcon } from "~/components/icons/Star";
|
||||
|
|
@ -280,7 +281,8 @@ export const action = async ({ request, params }: ActionArgs) => {
|
|||
return null;
|
||||
};
|
||||
|
||||
export const loader = ({ params }: LoaderArgs) => {
|
||||
export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const matchId = matchIdFromParams(params);
|
||||
const match = notFoundIfFalsy(findMatchById(matchId));
|
||||
|
||||
|
|
@ -289,10 +291,27 @@ export const loader = ({ params }: LoaderArgs) => {
|
|||
const groupBravo = groupForMatch(match.bravoGroupId);
|
||||
invariant(groupBravo, "Group bravo not found");
|
||||
|
||||
const censoredGroupAlpha = { ...groupAlpha, chatCode: undefined };
|
||||
const censoredGroupBravo = { ...groupBravo, chatCode: undefined };
|
||||
const censoredMatch = { ...match, chatCode: undefined };
|
||||
|
||||
const isTeamAlphaMember = groupAlpha.members.some((m) => m.id === user?.id);
|
||||
const isTeamBravoMember = groupBravo.members.some((m) => m.id === user?.id);
|
||||
const canAccessMatchChat = isTeamAlphaMember || isTeamBravoMember;
|
||||
|
||||
const groupChatCode = () => {
|
||||
if (isTeamAlphaMember) return groupAlpha.chatCode;
|
||||
if (isTeamBravoMember) return groupBravo.chatCode;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
match,
|
||||
groupAlpha,
|
||||
groupBravo,
|
||||
match: censoredMatch,
|
||||
matchChatCode: canAccessMatchChat ? match.chatCode : null,
|
||||
groupChatCode: groupChatCode(),
|
||||
groupAlpha: censoredGroupAlpha,
|
||||
groupBravo: censoredGroupBravo,
|
||||
reportedWeapons: match.reportedAt
|
||||
? reportedWeaponsByMatchId(matchId)
|
||||
: undefined,
|
||||
|
|
@ -338,6 +357,22 @@ export default function QMatchPage() {
|
|||
return `SQ${lastDigit}`;
|
||||
};
|
||||
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
[...data.groupAlpha.members, ...data.groupBravo.members].map((m) => [
|
||||
m.id,
|
||||
m,
|
||||
]),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const chatRooms = React.useMemo(() => {
|
||||
return [
|
||||
data.matchChatCode ? { code: data.matchChatCode, label: `Match` } : null,
|
||||
data.groupChatCode ? { code: data.groupChatCode, label: "Group" } : null,
|
||||
].filter(Boolean) as ChatProps["rooms"];
|
||||
}, [data.matchChatCode, data.groupChatCode]);
|
||||
|
||||
return (
|
||||
<Main className="q-match__container stack lg">
|
||||
<div className="q-match__header">
|
||||
|
|
@ -379,7 +414,11 @@ export default function QMatchPage() {
|
|||
) : null}
|
||||
{!showWeaponsForm ? (
|
||||
<>
|
||||
<div className="q-match__teams-container">
|
||||
<div
|
||||
className={clsx("q-match__teams-container", {
|
||||
"with-chat": data.matchChatCode || data.groupChatCode,
|
||||
})}
|
||||
>
|
||||
<MatchGroup
|
||||
group={data.groupAlpha}
|
||||
side="ALPHA"
|
||||
|
|
@ -390,8 +429,11 @@ export default function QMatchPage() {
|
|||
side="BRAVO"
|
||||
showWeapons={!data.match.isLocked}
|
||||
/>
|
||||
{chatRooms.length > 0 ? (
|
||||
<ConnectedChat users={chatUsers} rooms={chatRooms} />
|
||||
) : null}
|
||||
</div>
|
||||
{!data.match.isLocked && ownMember ? (
|
||||
{!data.match.isLocked && (ownMember || isAdmin(user)) ? (
|
||||
<div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<Link to={SENDOUQ_RULES_PAGE} className="text-xxs font-bold">
|
||||
|
|
@ -433,8 +475,13 @@ export default function QMatchPage() {
|
|||
{SENDOU_INK_DISCORD_URL}
|
||||
</a>
|
||||
. Alpha team hosts. Password should be{" "}
|
||||
<b>{resolveRoomPass(data.match.id)}</b>. Pool code is{" "}
|
||||
<b>{poolCode()}</b>
|
||||
<span className="q-match__join-discord-section__highlighted">
|
||||
{resolveRoomPass(data.match.id)}
|
||||
</span>
|
||||
. Pool code is{" "}
|
||||
<span className="q-match__join-discord-section__highlighted">
|
||||
{poolCode()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -773,7 +820,7 @@ function MatchGroup({
|
|||
side,
|
||||
showWeapons,
|
||||
}: {
|
||||
group: GroupForMatch;
|
||||
group: Omit<GroupForMatch, "chatCode">;
|
||||
side: "ALPHA" | "BRAVO";
|
||||
showWeapons: boolean;
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -25,17 +25,29 @@ export function matchIdFromParams(params: Params<string>) {
|
|||
return result;
|
||||
}
|
||||
|
||||
const passNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
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"],
|
||||
};
|
||||
export function resolveRoomPass(matchId: TournamentMatch["id"]) {
|
||||
let result = "";
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let pass = "5";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { shuffle } = seededRandom(`${matchId}-${i}`);
|
||||
|
||||
result += shuffle(passNumbers)[0];
|
||||
const key = pass[i] as keyof typeof NUM_MAP;
|
||||
const opts = NUM_MAP[key];
|
||||
const next = shuffle(opts)[0];
|
||||
pass += next;
|
||||
}
|
||||
|
||||
return result;
|
||||
return pass;
|
||||
}
|
||||
|
||||
export function resolveHostingTeam(
|
||||
|
|
|
|||
|
|
@ -530,6 +530,42 @@ dialog::backdrop {
|
|||
visibility: initial;
|
||||
}
|
||||
|
||||
.tab__buttons-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.tab__button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
font-size: var(--fonts-xs);
|
||||
border-radius: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-lighter);
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tab__button:active {
|
||||
transform: initial;
|
||||
}
|
||||
|
||||
.tab__number {
|
||||
color: var(--theme);
|
||||
margin-inline-start: var(--s-2);
|
||||
}
|
||||
|
||||
.tab__button:focus-visible {
|
||||
color: var(--theme) !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tab__button[data-headlessui-state="selected"] {
|
||||
border-color: var(--theme);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
z-index: 1;
|
||||
max-width: 20rem;
|
||||
|
|
@ -1279,6 +1315,87 @@ dialog::backdrop {
|
|||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.chat__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat__messages {
|
||||
padding: var(--s-2) 0 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
height: 270px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chat__message {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: var(--s-2-5);
|
||||
}
|
||||
|
||||
.chat__message__user {
|
||||
font-weight: var(--semi-bold);
|
||||
font-size: var(--fonts-sm);
|
||||
color: var(--chat-user-color);
|
||||
}
|
||||
|
||||
.chat__message__time {
|
||||
font-size: var(--fonts-xxs);
|
||||
color: var(--text-lighter);
|
||||
margin-block-start: 3px;
|
||||
}
|
||||
|
||||
.chat__input-container {
|
||||
margin-top: auto;
|
||||
|
||||
--input-width: 100%;
|
||||
}
|
||||
|
||||
.chat__message__contents {
|
||||
font-size: var(--fonts-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chat__message__contents.pending {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chat__room-button {
|
||||
background-color: var(--bg-light);
|
||||
color: var(--text-lighter);
|
||||
border-radius: var(--rounded) var(--rounded) 0 0;
|
||||
font-size: var(--fonts-xs);
|
||||
padding: var(--s-1) var(--s-1);
|
||||
border-color: var(--bg-light-variation);
|
||||
}
|
||||
|
||||
.chat__room-button.not-first {
|
||||
margin-inline-start: -1px;
|
||||
}
|
||||
|
||||
.chat__room-button.current {
|
||||
background-color: var(--bg-lighter);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat__room-button__unseen {
|
||||
color: var(--theme-secondary);
|
||||
text-shadow: var(--fonts-xxxs);
|
||||
margin-inline-start: var(--s-1);
|
||||
width: 25px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat__bottom-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-block-start: var(--s-2);
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.winner__container {
|
||||
height: 125px;
|
||||
width: 125px;
|
||||
|
|
|
|||
|
|
@ -234,6 +234,10 @@
|
|||
margin-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.my-auto {
|
||||
margin-block: auto;
|
||||
}
|
||||
|
||||
.my-2 {
|
||||
margin-block: var(--s-2);
|
||||
}
|
||||
|
|
@ -266,6 +270,10 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ html {
|
|||
--fonts-lg: 1.2rem;
|
||||
--fonts-md: 1rem;
|
||||
--fonts-sm: 0.92rem;
|
||||
--fonts-xsm: 0.85rem;
|
||||
--fonts-xs: 0.8rem;
|
||||
--fonts-xxs: 0.7rem;
|
||||
--fonts-xxxs: 0.6rem;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ const staticAssetsUrl = ({
|
|||
}) =>
|
||||
`https://raw.githubusercontent.com/Sendouc/sendou-ink-assets/main/${folder}/${fileName}`;
|
||||
|
||||
export const SKALOP_BASE_URL = "ws://localhost:5900";
|
||||
|
||||
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
|
||||
|
||||
const USER_SUBMITTED_IMAGE_ROOT = "https://sendou.nyc3.digitaloceanspaces.com";
|
||||
|
|
|
|||
6
migrations/036-add-chat-code.js
Normal file
6
migrations/036-add-chat-code.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports.up = function (db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(/* sql */ `alter table "Group" add "chatCode" text`).run();
|
||||
db.prepare(/* sql */ `alter table "GroupMatch" add "chatCode" text`).run();
|
||||
})();
|
||||
};
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -50,6 +50,7 @@
|
|||
"react-popper": "^2.3.0",
|
||||
"react-responsive-masonry": "^2.1.7",
|
||||
"react-use": "^17.4.0",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"remix-auth": "^3.5.1",
|
||||
"remix-auth-oauth2": "^1.8.0",
|
||||
"remix-i18next": "^4.1.1",
|
||||
|
|
@ -12688,6 +12689,11 @@
|
|||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reconnecting-websocket": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz",
|
||||
"integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng=="
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz",
|
||||
|
|
@ -24245,6 +24251,11 @@
|
|||
"picomatch": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"reconnecting-websocket": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz",
|
||||
"integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng=="
|
||||
},
|
||||
"redent": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@
|
|||
"react-popper": "^2.3.0",
|
||||
"react-responsive-masonry": "^2.1.7",
|
||||
"react-use": "^17.4.0",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"remix-auth": "^3.5.1",
|
||||
"remix-auth-oauth2": "^1.8.0",
|
||||
"remix-i18next": "^4.1.1",
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@
|
|||
"support.perk.nameInFooter": "Name in the footer",
|
||||
"support.perk.discordColorRole": "Discord color role",
|
||||
"support.perk.discordColorRole.extra": "You can have a custom color role on Plus Server and sendou.ink's Discord server. Use the /color command of the Lohi bot.",
|
||||
"support.perk.chatColor": "Choose name color in the sendou.ink chat",
|
||||
"support.perk.badge": "Profile badge",
|
||||
"support.perk.seePlusPercentage": "See Plus Server voting percentage",
|
||||
"support.perk.seePlusPercentage.extra": "Normally only failed suggests see their voting percentage. With this, you can always see your own percentage.",
|
||||
|
|
@ -179,6 +180,7 @@
|
|||
"custom.colors.text": "Text",
|
||||
"custom.colors.text-lighter": "Text lighter",
|
||||
"custom.colors.theme": "Theme",
|
||||
"custom.colors.chat": "Chat name",
|
||||
|
||||
"divisions.WEST": "Tentatek",
|
||||
"divisions.JPN": "Takoroka",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
"mapListPreference.ALL_MODES_ONLY": "All modes only",
|
||||
"mapListPreference.SZ_ONLY": "SZ only",
|
||||
"mapListPreference.note": "Note: you will not be able to match with teams who chose \"{{optionOne}}\". Consider selecting \"{{optionTwo}}\".",
|
||||
"roles.OWNER": "Owner",
|
||||
"roles.MANAGER": "Manager",
|
||||
"roles.REGULAR": "Regular member",
|
||||
"vc.YES": "Can voice chat",
|
||||
"vc.NO": "Can't voice chat",
|
||||
"vc.LISTEN_ONLY": "Can only listen"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user