mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-26 20:43:36 -05:00
Progress
This commit is contained in:
@@ -20,7 +20,7 @@ import { useUser } from "~/features/auth/core/user";
|
||||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
import { FriendMenu } from "~/features/friends/components/FriendMenu";
|
||||
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
|
||||
import { useLayoutSize } from "~/hooks/useWindowSize";
|
||||
import { useLayoutSize } from "~/hooks/useMainContentWidth";
|
||||
import type { RootLoaderData } from "~/root";
|
||||
import {
|
||||
EVENTS_PAGE,
|
||||
|
||||
@@ -62,9 +62,12 @@ function RoomList({ onClose }: { onClose?: () => void }) {
|
||||
const chatContext = useChatContext()!;
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
const nonExpiredRooms = chatContext.rooms.filter(
|
||||
(room) => room.expiresAt > Date.now(),
|
||||
);
|
||||
const nonExpiredRooms = chatContext.rooms
|
||||
.filter((room) => room.expiresAt > Date.now())
|
||||
.sort((a, b) => {
|
||||
if (a.isObsolete !== b.isObsolete) return a.isObsolete ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.sidebar}>
|
||||
@@ -81,7 +84,11 @@ function RoomList({ onClose }: { onClose?: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
key={room.chatCode}
|
||||
className={clsx(sideNavStyles.listButton, styles.roomItem)}
|
||||
className={clsx(
|
||||
sideNavStyles.listButton,
|
||||
styles.roomItem,
|
||||
room.isObsolete ? "opaque" : null,
|
||||
)}
|
||||
onPress={() => {
|
||||
chatContext.requestHistory(room.chatCode);
|
||||
chatContext.setActiveRoom(room.chatCode);
|
||||
@@ -100,6 +107,7 @@ function RoomList({ onClose }: { onClose?: () => void }) {
|
||||
className={clsx(
|
||||
sideNavStyles.listLinkTitle,
|
||||
styles.roomName,
|
||||
room.isObsolete ? "line-through" : null,
|
||||
)}
|
||||
>
|
||||
{resolveDatePlaceholders(room.header, (d) =>
|
||||
@@ -115,7 +123,7 @@ function RoomList({ onClose }: { onClose?: () => void }) {
|
||||
{room.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
{unread > 0 ? (
|
||||
{unread > 0 && !room.isObsolete ? (
|
||||
<span className={styles.unreadBadge}>{unread}</span>
|
||||
) : room.lastMessageTimestamp > 0 ? (
|
||||
<span className={styles.roomTimestamp}>
|
||||
@@ -170,7 +178,12 @@ function ChatView({ onClose }: { onClose?: () => void }) {
|
||||
/>
|
||||
) : null}
|
||||
<div className={styles.chatHeaderInfo}>
|
||||
<span className={styles.chatHeaderTitle}>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.chatHeaderTitle,
|
||||
room?.isObsolete ? "line-through" : null,
|
||||
)}
|
||||
>
|
||||
{resolveDatePlaceholders(room?.header ?? activeRoom, (d) =>
|
||||
formatDateTime(d, {
|
||||
month: "short",
|
||||
|
||||
@@ -202,6 +202,12 @@ export function Layout({
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally close modals on navigation
|
||||
React.useEffect(() => {
|
||||
setSideNavModalOpen(false);
|
||||
setChatSidebarModalOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const user = useUser();
|
||||
const { unseenIds } = useNotifications();
|
||||
const sidebarData = data?.sidebar;
|
||||
@@ -316,7 +322,6 @@ export function Layout({
|
||||
<SiteLogoContent />
|
||||
</Link>
|
||||
<DialogTrigger
|
||||
key={location.pathname}
|
||||
isOpen={sideNavModalOpen}
|
||||
onOpenChange={setSideNavModalOpen}
|
||||
>
|
||||
|
||||
@@ -56,7 +56,6 @@ import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { stagesObj as s, stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import type {
|
||||
AbilityType,
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
@@ -220,7 +219,6 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
||||
calendarEventWithToToolsTeamsDepths,
|
||||
calendarEventWithToToolsLUTI,
|
||||
calendarEventWithToToolsTeamsLUTI,
|
||||
tournamentSubs,
|
||||
variation === "NO_TOURNAMENT_TEAMS" ? undefined : tournamentLfgGroups,
|
||||
adminBuilds,
|
||||
manySplattershotBuilds,
|
||||
@@ -1565,80 +1563,19 @@ function calendarEventWithToToolsTeams(
|
||||
}
|
||||
}
|
||||
|
||||
function tournamentSubs() {
|
||||
for (let id = 100; id < 120; id++) {
|
||||
const includedWeaponIds: MainWeaponId[] = [];
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
/* sql */ `
|
||||
insert into "TournamentSub" (
|
||||
"userId",
|
||||
"tournamentId",
|
||||
"canVc",
|
||||
"bestWeapons",
|
||||
"okWeapons",
|
||||
"message",
|
||||
"visibility"
|
||||
) values (
|
||||
@userId,
|
||||
@tournamentId,
|
||||
@canVc,
|
||||
@bestWeapons,
|
||||
@okWeapons,
|
||||
@message,
|
||||
@visibility
|
||||
)
|
||||
`,
|
||||
)
|
||||
.run({
|
||||
userId: id,
|
||||
tournamentId: 1,
|
||||
canVc: Number(faker.number.float(1) > 0.5),
|
||||
bestWeapons: nullFilledArray(
|
||||
faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]),
|
||||
)
|
||||
// biome-ignore lint/suspicious/useIterableCallbackReturn: Biome 2.3.1 upgrade
|
||||
.map(() => {
|
||||
while (true) {
|
||||
const weaponId = R.sample(mainWeaponIds, 1)[0]!;
|
||||
if (!includedWeaponIds.includes(weaponId)) {
|
||||
includedWeaponIds.push(weaponId);
|
||||
return weaponId;
|
||||
}
|
||||
}
|
||||
})
|
||||
.join(","),
|
||||
okWeapons:
|
||||
faker.number.float(1) > 0.5
|
||||
? null
|
||||
: nullFilledArray(
|
||||
faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]),
|
||||
)
|
||||
// biome-ignore lint/suspicious/useIterableCallbackReturn: Biome 2.3.1 upgrade
|
||||
.map(() => {
|
||||
while (true) {
|
||||
const weaponId = R.sample(mainWeaponIds, 1)[0]!;
|
||||
if (!includedWeaponIds.includes(weaponId)) {
|
||||
includedWeaponIds.push(weaponId);
|
||||
return weaponId;
|
||||
}
|
||||
}
|
||||
})
|
||||
.join(","),
|
||||
message: faker.number.float(1) > 0.5 ? null : faker.lorem.paragraph(),
|
||||
visibility: id < 105 ? "+1" : id < 110 ? "+2" : id < 115 ? "+2" : "ALL",
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function tournamentLfgGroups() {
|
||||
const availableUsers = userIdsInAscendingOrderById().slice(300);
|
||||
|
||||
const MAX_GROUP_SIZE = 6;
|
||||
|
||||
// Add admin's friends to tournament LFG so sidebar shows tournament friends
|
||||
for (const friendId of SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG) {
|
||||
await TournamentLFGRepository.createPlaceholderTeam({
|
||||
tournamentId: 1,
|
||||
userId: friendId,
|
||||
});
|
||||
}
|
||||
|
||||
const tournaments = [1, 2, 3];
|
||||
|
||||
let userIndex = 0;
|
||||
@@ -2895,12 +2832,14 @@ async function organization() {
|
||||
}
|
||||
|
||||
const SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS = [150, 151, 152, 153];
|
||||
const SENDOU_FRIEND_IDS_AS_TOURNAMENT_SUBS = [100, 101, 102, 103];
|
||||
const SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG = [100, 101];
|
||||
const SENDOU_FRIEND_IDS_OTHER = [102, 103];
|
||||
|
||||
async function friendships(variation?: SeedVariation | null) {
|
||||
const allFriendIds = [
|
||||
...SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS,
|
||||
...SENDOU_FRIEND_IDS_AS_TOURNAMENT_SUBS,
|
||||
...SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG,
|
||||
...SENDOU_FRIEND_IDS_OTHER,
|
||||
];
|
||||
|
||||
for (const friendId of allFriendIds) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from "~/db/sql";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
|
||||
import { ASSOCIATION } from "~/features/associations/associations-constants";
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import { LimitReachedError } from "~/utils/errors";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
@@ -28,6 +29,7 @@ export async function findByMemberUserId(
|
||||
return {
|
||||
actual: await findBy({ type: "user", userId }, options),
|
||||
virtual: await virtualAssociationsByUserId(userId),
|
||||
friendIds: await FriendRepository.findFriendIds(userId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +96,10 @@ async function findBy(
|
||||
}));
|
||||
}
|
||||
|
||||
const DEFAULT_VIRTUAL_ASSOCIATIONS: Array<AssociationVirtualIdentifier> = [
|
||||
"FRIENDS",
|
||||
];
|
||||
|
||||
async function virtualAssociationsByUserId(
|
||||
userId: number,
|
||||
): Promise<Array<AssociationVirtualIdentifier>> {
|
||||
@@ -103,14 +109,16 @@ async function virtualAssociationsByUserId(
|
||||
.select(["PlusTier.tier as plusTier"])
|
||||
.where("userId", "=", userId)
|
||||
.executeTakeFirst()) ?? {};
|
||||
if (!plusTier) return [];
|
||||
if (!plusTier) return [...DEFAULT_VIRTUAL_ASSOCIATIONS];
|
||||
|
||||
if (plusTier === 1) return ["+1", "+2", "+3"] as const;
|
||||
if (plusTier === 2) return ["+2", "+3"] as const;
|
||||
if (plusTier === 3) return ["+3"] as const;
|
||||
if (plusTier === 1)
|
||||
return [...DEFAULT_VIRTUAL_ASSOCIATIONS, "+1", "+2", "+3"] as const;
|
||||
if (plusTier === 2)
|
||||
return [...DEFAULT_VIRTUAL_ASSOCIATIONS, "+2", "+3"] as const;
|
||||
if (plusTier === 3) return [...DEFAULT_VIRTUAL_ASSOCIATIONS, "+3"] as const;
|
||||
|
||||
logger.error("Invalid plusTier", { plusTier });
|
||||
return [];
|
||||
return [...DEFAULT_VIRTUAL_ASSOCIATIONS];
|
||||
}
|
||||
|
||||
type InsertArgs = Omit<TablesInsertable["Association"], "inviteCode"> & {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const ASSOCIATION = {
|
||||
VIRTUAL_IDENTIFIERS: ["+1", "+2", "+3"] as const,
|
||||
VIRTUAL_IDENTIFIERS: ["+1", "+2", "+3", "FRIENDS"] as const,
|
||||
MAX_COUNT_REGULAR_USER: 3,
|
||||
MAX_COUNT_SUPPORTER: 6,
|
||||
MAX_ASSOCIATION_MEMBER_COUNT: 300,
|
||||
|
||||
@@ -103,6 +103,63 @@ describe("isVisible", () => {
|
||||
expect(Association.isVisible(args)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if viewer is a friend of the content owner", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: { forAssociation: "FRIENDS" },
|
||||
time: new Date(),
|
||||
associations: {
|
||||
actual: [],
|
||||
virtual: [],
|
||||
friendIds: [42],
|
||||
},
|
||||
contentOwnerUserId: 42,
|
||||
};
|
||||
expect(Association.isVisible(args)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if viewer is not a friend of the content owner", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: { forAssociation: "FRIENDS" },
|
||||
time: new Date(),
|
||||
associations: {
|
||||
actual: [],
|
||||
virtual: [],
|
||||
friendIds: [99],
|
||||
},
|
||||
contentOwnerUserId: 42,
|
||||
};
|
||||
expect(Association.isVisible(args)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for FRIENDS visibility when not logged in", () => {
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: { forAssociation: "FRIENDS" },
|
||||
time: new Date(),
|
||||
associations: null,
|
||||
};
|
||||
expect(Association.isVisible(args)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when FRIENDS visibility becomes public via notFoundInstructions", () => {
|
||||
const visibleAt = add(new Date(), { days: 1 });
|
||||
|
||||
const args: Association.IsVisibleArgs = {
|
||||
visibility: {
|
||||
forAssociation: "FRIENDS",
|
||||
notFoundInstructions: [
|
||||
{ at: dateToDatabaseTimestamp(visibleAt), forAssociation: null },
|
||||
],
|
||||
},
|
||||
time: add(new Date(), { days: 2 }),
|
||||
associations: {
|
||||
actual: [],
|
||||
virtual: [],
|
||||
friendIds: [],
|
||||
},
|
||||
};
|
||||
expect(Association.isVisible(args)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if has become public (no associations)", () => {
|
||||
const visibleAt = add(new Date(), { days: 1 });
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ export interface IsVisibleArgs {
|
||||
associations: {
|
||||
virtual: Array<string>;
|
||||
actual: Array<{ id: number }>;
|
||||
friendIds?: Array<number>;
|
||||
} | null;
|
||||
contentOwnerUserId?: number;
|
||||
}
|
||||
|
||||
export function isVisible(args: IsVisibleArgs) {
|
||||
@@ -29,6 +31,14 @@ export function isVisible(args: IsVisibleArgs) {
|
||||
|
||||
if (isPublic) return true;
|
||||
|
||||
if (
|
||||
currentVisibility.includes("FRIENDS") &&
|
||||
args.contentOwnerUserId &&
|
||||
args.associations?.friendIds?.includes(args.contentOwnerUserId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
args.associations?.actual.some((association) =>
|
||||
currentVisibility.includes(association.id),
|
||||
|
||||
@@ -72,12 +72,12 @@ export default function EventsPage() {
|
||||
)}
|
||||
</div>
|
||||
{hasNoEventsAtAll ? (
|
||||
<p className="text-lighter text-sm">
|
||||
<p className="no-results mt-4">
|
||||
{t("calendar:events.emptyAll")}{" "}
|
||||
<Link to={CALENDAR_PAGE}>{t("calendar:events.findOnCalendar")}</Link>
|
||||
</p>
|
||||
) : shownEvents.length === 0 ? (
|
||||
<p className="text-lighter text-sm">{t("calendar:events.empty")}</p>
|
||||
<p className="no-results mt-4">{t("calendar:events.empty")}</p>
|
||||
) : (
|
||||
<EventsList events={shownEvents} />
|
||||
)}
|
||||
|
||||
@@ -32,9 +32,49 @@ function flattenServerRoom(serverRoom: ServerRoomInfo): RoomInfo {
|
||||
expiresAt: serverRoom.metadata.expiresAt,
|
||||
lastMessageTimestamp: serverRoom.lastMessageTimestamp ?? 0,
|
||||
totalMessageCount: serverRoom.totalMessageCount,
|
||||
createdAt: serverRoom.metadata.createdAt ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const SENDOUQ_GROUP_HEADER_PATTERN = /^Group\b/;
|
||||
|
||||
function isSendouQGroupRoom(room: RoomInfo) {
|
||||
return (
|
||||
room.subtitle === "SendouQ" &&
|
||||
SENDOUQ_GROUP_HEADER_PATTERN.test(room.header)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveObsoleteGroupRooms(rooms: RoomInfo[]): RoomInfo[] {
|
||||
const groupRooms = rooms.filter(isSendouQGroupRoom);
|
||||
|
||||
if (groupRooms.length <= 1) return rooms;
|
||||
|
||||
const newestCreatedAt = Math.max(...groupRooms.map((r) => r.createdAt));
|
||||
|
||||
// If no room has createdAt set, skip resolution
|
||||
if (newestCreatedAt === 0) return rooms;
|
||||
|
||||
const obsoleteChatCodes = new Set<string>();
|
||||
const removedChatCodes = new Set<string>();
|
||||
|
||||
for (const room of groupRooms) {
|
||||
if (room.createdAt === newestCreatedAt) continue;
|
||||
|
||||
if (room.totalMessageCount === 0) {
|
||||
removedChatCodes.add(room.chatCode);
|
||||
} else {
|
||||
obsoleteChatCodes.add(room.chatCode);
|
||||
}
|
||||
}
|
||||
|
||||
return rooms
|
||||
.filter((r) => !removedChatCodes.has(r.chatCode))
|
||||
.map((r) =>
|
||||
obsoleteChatCodes.has(r.chatCode) ? { ...r, isObsolete: true } : r,
|
||||
);
|
||||
}
|
||||
|
||||
function localStorageKey(chatCode: string) {
|
||||
return `${LOCAL_STORAGE_PREFIX}${chatCode}`;
|
||||
}
|
||||
@@ -116,7 +156,9 @@ function ChatProviderInner({
|
||||
if (parsed.rooms && Array.isArray(parsed.rooms)) {
|
||||
logger.debug("WS initial rooms payload, count:", parsed.rooms.length);
|
||||
const serverRooms = parsed.rooms as ServerRoomInfo[];
|
||||
const roomList = serverRooms.map(flattenServerRoom);
|
||||
const roomList = resolveObsoleteGroupRooms(
|
||||
serverRooms.map(flattenServerRoom),
|
||||
);
|
||||
setRooms(roomList);
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -157,13 +199,20 @@ function ChatProviderInner({
|
||||
setRooms((prev) => {
|
||||
const exists = prev.some((r) => r.chatCode === newRoom.chatCode);
|
||||
if (exists) return prev;
|
||||
return [...prev, newRoom];
|
||||
return resolveObsoleteGroupRooms([...prev, newRoom]);
|
||||
});
|
||||
|
||||
setChatUsersCache((prev) => ({
|
||||
...prev,
|
||||
...serverRoom.metadata.chatUsers,
|
||||
}));
|
||||
|
||||
const lastRead = readLastReadCount(serverRoom.chatCode);
|
||||
const unread = Math.max(0, serverRoom.totalMessageCount - lastRead);
|
||||
setUnreadCounts((prev) => ({
|
||||
...prev,
|
||||
[serverRoom.chatCode]: unread,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -194,6 +243,7 @@ function ChatProviderInner({
|
||||
expiresAt: metadata.expiresAt,
|
||||
lastMessageTimestamp: messages.at(-1)?.timestamp ?? 0,
|
||||
totalMessageCount: messages.length,
|
||||
createdAt: metadata.createdAt ?? 0,
|
||||
};
|
||||
setRooms((prev) => {
|
||||
const exists = prev.some((r) => r.chatCode === chatCode);
|
||||
@@ -212,7 +262,7 @@ function ChatProviderInner({
|
||||
// Regular message(s)
|
||||
const messageArr = (
|
||||
Array.isArray(parsed) ? parsed : [parsed]
|
||||
) as (ChatMessage & { totalMessageCount?: number })[];
|
||||
) as (ChatMessage & { totalMessageCount: number })[];
|
||||
|
||||
const isSystemMessage = Boolean(messageArr[0].type);
|
||||
logger.debug(
|
||||
@@ -253,30 +303,31 @@ function ChatProviderInner({
|
||||
return { ...prev, [roomCode]: [...existing, msg] };
|
||||
});
|
||||
|
||||
if (msg.totalMessageCount) {
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.chatCode === roomCode
|
||||
? {
|
||||
...r,
|
||||
lastMessageTimestamp: msg.timestamp,
|
||||
totalMessageCount: Math.max(
|
||||
r.totalMessageCount,
|
||||
msg.totalMessageCount!,
|
||||
),
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
setRooms((prev) =>
|
||||
prev.map((r) =>
|
||||
r.chatCode === roomCode
|
||||
? {
|
||||
...r,
|
||||
lastMessageTimestamp: msg.timestamp,
|
||||
totalMessageCount: Math.max(
|
||||
r.totalMessageCount,
|
||||
msg.totalMessageCount!,
|
||||
),
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
|
||||
if (roomCode === activeRoom && chatOpen) {
|
||||
writeLastReadCount(roomCode, msg.totalMessageCount);
|
||||
} else {
|
||||
setUnreadCounts((prev) => ({
|
||||
...prev,
|
||||
[roomCode]: (prev[roomCode] ?? 0) + 1,
|
||||
}));
|
||||
}
|
||||
if (roomCode === activeRoom && chatOpen) {
|
||||
writeLastReadCount(roomCode, msg.totalMessageCount);
|
||||
} else {
|
||||
setUnreadCounts((prev) => ({
|
||||
...prev,
|
||||
[roomCode]: Math.max(
|
||||
0,
|
||||
msg.totalMessageCount - readLastReadCount(roomCode),
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -323,7 +374,8 @@ function ChatProviderInner({
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (!e.key?.startsWith(LOCAL_STORAGE_PREFIX)) return;
|
||||
const chatCode = e.key.slice(LOCAL_STORAGE_PREFIX.length);
|
||||
const newCount = e.newValue ? Number(e.newValue) : 0;
|
||||
const parsed = Number(e.newValue);
|
||||
const newCount = Number.isFinite(parsed) ? parsed : 0;
|
||||
|
||||
setUnreadCounts((prev) => {
|
||||
const room = rooms.find((r) => r.chatCode === chatCode);
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface RoomMetadata {
|
||||
subtitle?: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
/** Room info as returned by Skalop on connect and ROOM_JOINED events */
|
||||
@@ -30,6 +31,8 @@ export interface RoomInfo {
|
||||
expiresAt: number;
|
||||
lastMessageTimestamp: number;
|
||||
totalMessageCount: number;
|
||||
createdAt: number;
|
||||
isObsolete?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatContextValue {
|
||||
|
||||
@@ -1,87 +1,52 @@
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
|
||||
export async function findByUserIdWithActivity(userId: number) {
|
||||
const [friendRows, teamMemberRows] = await Promise.all([
|
||||
db
|
||||
.selectFrom("Friendship")
|
||||
.innerJoin("User", (join) =>
|
||||
join.on((eb) =>
|
||||
withLfgJoins(
|
||||
db
|
||||
.selectFrom("Friendship")
|
||||
.innerJoin("User", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb.and([
|
||||
eb("Friendship.userOneId", "=", userId),
|
||||
eb("User.id", "=", eb.ref("Friendship.userTwoId")),
|
||||
]),
|
||||
eb.and([
|
||||
eb("Friendship.userTwoId", "=", userId),
|
||||
eb("User.id", "=", eb.ref("Friendship.userOneId")),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.and([
|
||||
eb("Friendship.userOneId", "=", userId),
|
||||
eb("User.id", "=", eb.ref("Friendship.userTwoId")),
|
||||
]),
|
||||
eb.and([
|
||||
eb("Friendship.userTwoId", "=", userId),
|
||||
eb("User.id", "=", eb.ref("Friendship.userOneId")),
|
||||
]),
|
||||
eb("Friendship.userOneId", "=", userId),
|
||||
eb("Friendship.userTwoId", "=", userId),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.leftJoin("TournamentSub", "TournamentSub.userId", "User.id")
|
||||
.leftJoin(
|
||||
"CalendarEvent",
|
||||
"CalendarEvent.tournamentId",
|
||||
"TournamentSub.tournamentId",
|
||||
)
|
||||
.leftJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEventDate.eventId",
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
)
|
||||
.select([
|
||||
"Friendship.id as friendshipId",
|
||||
"User.id",
|
||||
"User.username",
|
||||
"User.discordId",
|
||||
"User.discordAvatar",
|
||||
"User.customUrl",
|
||||
"CalendarEvent.name as tournamentName",
|
||||
"TournamentSub.tournamentId",
|
||||
"CalendarEventDate.startTime as tournamentStartTime",
|
||||
"Friendship.createdAt as friendshipCreatedAt",
|
||||
])
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("Friendship.userOneId", "=", userId),
|
||||
eb("Friendship.userTwoId", "=", userId),
|
||||
]),
|
||||
)
|
||||
.orderBy("Friendship.createdAt", "desc")
|
||||
.execute(),
|
||||
db
|
||||
.selectFrom("TeamMemberWithSecondary as myMembership")
|
||||
.innerJoin("TeamMemberWithSecondary as otherMembership", (join) =>
|
||||
join
|
||||
.onRef("otherMembership.teamId", "=", "myMembership.teamId")
|
||||
.on("otherMembership.userId", "!=", userId),
|
||||
)
|
||||
.innerJoin("User", "User.id", "otherMembership.userId")
|
||||
.leftJoin("TournamentSub", "TournamentSub.userId", "User.id")
|
||||
.leftJoin(
|
||||
"CalendarEvent",
|
||||
"CalendarEvent.tournamentId",
|
||||
"TournamentSub.tournamentId",
|
||||
)
|
||||
.leftJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEventDate.eventId",
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
.select([
|
||||
"User.id",
|
||||
"User.username",
|
||||
"User.discordId",
|
||||
"User.discordAvatar",
|
||||
"User.customUrl",
|
||||
"CalendarEvent.name as tournamentName",
|
||||
"TournamentSub.tournamentId",
|
||||
"CalendarEventDate.startTime as tournamentStartTime",
|
||||
])
|
||||
.where("myMembership.userId", "=", userId)
|
||||
.where("myMembership.leftAt", "is", null)
|
||||
.where("otherMembership.leftAt", "is", null)
|
||||
.execute(),
|
||||
withLfgJoins(
|
||||
db
|
||||
.selectFrom("TeamMemberWithSecondary as myMembership")
|
||||
.innerJoin("TeamMemberWithSecondary as otherMembership", (join) =>
|
||||
join
|
||||
.onRef("otherMembership.teamId", "=", "myMembership.teamId")
|
||||
.on("otherMembership.userId", "!=", userId),
|
||||
)
|
||||
.innerJoin("User", "User.id", "otherMembership.userId")
|
||||
.where("myMembership.userId", "=", userId),
|
||||
).execute(),
|
||||
]);
|
||||
|
||||
return [
|
||||
@@ -94,6 +59,63 @@ export async function findByUserIdWithActivity(userId: number) {
|
||||
];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function withLfgJoins<QB extends SelectQueryBuilder<any, any, any>>(qb: QB) {
|
||||
const nowTimestamp = dateToDatabaseTimestamp(new Date());
|
||||
|
||||
return (qb as SelectQueryBuilder<DB, keyof DB, Record<string, never>>)
|
||||
.leftJoin("TournamentTeamMember", (join) =>
|
||||
join
|
||||
.onRef("TournamentTeamMember.userId", "=", "User.id")
|
||||
.on("TournamentTeamMember.isLooking", "=", 1),
|
||||
)
|
||||
.leftJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.id",
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
)
|
||||
.leftJoin("Tournament", (join) =>
|
||||
join
|
||||
.onRef("Tournament.id", "=", "TournamentTeam.tournamentId")
|
||||
.on((eb) =>
|
||||
eb.or([
|
||||
eb(
|
||||
sql`json_extract("Tournament"."settings", '$.regClosesAt')`,
|
||||
"is",
|
||||
null,
|
||||
),
|
||||
eb(
|
||||
sql<number>`json_extract("Tournament"."settings", '$.regClosesAt')`,
|
||||
">",
|
||||
nowTimestamp,
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.leftJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
|
||||
.leftJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEventDate.eventId",
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
.select([
|
||||
...COMMON_USER_FIELDS,
|
||||
"CalendarEvent.name as tournamentName",
|
||||
"TournamentTeam.tournamentId",
|
||||
"CalendarEventDate.startTime as tournamentStartTime",
|
||||
sql<
|
||||
number | null
|
||||
>`(SELECT COUNT(*) FROM "TournamentTeamMember" "ttm" WHERE "ttm"."tournamentTeamId" = "TournamentTeam"."id")`.as(
|
||||
"teamMemberCount",
|
||||
),
|
||||
sql<
|
||||
number | null
|
||||
>`json_extract("Tournament"."settings", '$.minMembersPerTeam')`.as(
|
||||
"tournamentMinTeamSize",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function findPendingSentRequests(senderId: number) {
|
||||
return db
|
||||
.selectFrom("FriendRequest")
|
||||
@@ -333,6 +355,29 @@ export async function countPendingSentRequests(
|
||||
return result.count;
|
||||
}
|
||||
|
||||
export async function findFriendIds(userId: number): Promise<number[]> {
|
||||
const rows = await db
|
||||
.selectFrom("Friendship")
|
||||
.select((eb) =>
|
||||
eb
|
||||
.case()
|
||||
.when("Friendship.userOneId", "=", userId)
|
||||
.then(eb.ref("Friendship.userTwoId"))
|
||||
.else(eb.ref("Friendship.userOneId"))
|
||||
.end()
|
||||
.as("friendId"),
|
||||
)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("Friendship.userOneId", "=", userId),
|
||||
eb("Friendship.userTwoId", "=", userId),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => row.friendId);
|
||||
}
|
||||
|
||||
export async function findFriendship({
|
||||
userOneId,
|
||||
userTwoId,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { SENDOUQ_ACTIVITY_LABEL } from "./friends-constants";
|
||||
export function resolveFriendActivity(
|
||||
friendId: number,
|
||||
tournamentName: string | null,
|
||||
teamMemberCount: number | null,
|
||||
tournamentMinTeamSize: number | null,
|
||||
) {
|
||||
const ownGroup = SendouQ.findOwnGroup(friendId);
|
||||
|
||||
@@ -18,7 +20,7 @@ export function resolveFriendActivity(
|
||||
if (tournamentName) {
|
||||
return {
|
||||
subtitle: tournamentName,
|
||||
badge: `1/${FULL_GROUP_SIZE}`,
|
||||
badge: `${teamMemberCount ?? 1}/${tournamentMinTeamSize ?? FULL_GROUP_SIZE}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,12 @@ export const loader = async () => {
|
||||
const friends = unique
|
||||
.filter((f) => f.friendshipId !== null)
|
||||
.map((friend) => {
|
||||
const activity = resolveFriendActivity(friend.id, friend.tournamentName);
|
||||
const activity = resolveFriendActivity(
|
||||
friend.id,
|
||||
friend.tournamentName,
|
||||
friend.teamMemberCount,
|
||||
friend.tournamentMinTeamSize,
|
||||
);
|
||||
|
||||
return {
|
||||
id: friend.id,
|
||||
@@ -51,7 +56,12 @@ export const loader = async () => {
|
||||
const teamMembers = unique
|
||||
.filter((f) => f.friendshipId === null)
|
||||
.map((tm) => {
|
||||
const activity = resolveFriendActivity(tm.id, tm.tournamentName);
|
||||
const activity = resolveFriendActivity(
|
||||
tm.id,
|
||||
tm.tournamentName,
|
||||
tm.teamMemberCount,
|
||||
tm.tournamentMinTeamSize,
|
||||
);
|
||||
|
||||
return {
|
||||
id: tm.id,
|
||||
|
||||
@@ -170,9 +170,9 @@ function FriendsListSection() {
|
||||
: "friends:friendsList.empty";
|
||||
|
||||
return (
|
||||
<section>
|
||||
<section className="stack md">
|
||||
<div className={styles.friendsListHeader}>
|
||||
<h2 className="text-lg">{t("friends:friendsList.title")}</h2>
|
||||
<h2 className="text-lg ml-1">{t("friends:friendsList.title")}</h2>
|
||||
<SubNav secondary>
|
||||
{VIEW_FILTERS.map((value) => (
|
||||
<SubNavLink
|
||||
@@ -188,15 +188,17 @@ function FriendsListSection() {
|
||||
))}
|
||||
</SubNav>
|
||||
</div>
|
||||
{shownItems.length === 0 ? (
|
||||
<p className="text-lighter text-sm">{t(emptyKey)}</p>
|
||||
) : (
|
||||
<div className="stack xs">
|
||||
{shownItems.map((item) => (
|
||||
<FriendMenu key={item.id} name={item.username} {...item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{shownItems.length === 0 ? (
|
||||
<p className="no-results">{t(emptyKey)}</p>
|
||||
) : (
|
||||
<div className="stack xs">
|
||||
{shownItems.map((item) => (
|
||||
<FriendMenu key={item.id} name={item.username} {...item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
associations,
|
||||
time: now,
|
||||
visibility: post.visibility,
|
||||
contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
|
||||
}),
|
||||
)
|
||||
.map((post) => ({
|
||||
|
||||
@@ -257,7 +257,9 @@ function AssociationSelect({
|
||||
<option value="PUBLIC">{t("scrims:forms.visibility.public")}</option>
|
||||
{associations.virtual.map((association) => (
|
||||
<option key={association} value={association}>
|
||||
{association}
|
||||
{association === "FRIENDS"
|
||||
? t("scrims:forms.visibility.friends")
|
||||
: association}
|
||||
</option>
|
||||
))}
|
||||
{associations.actual.map((association) => (
|
||||
|
||||
@@ -34,8 +34,8 @@ import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPri
|
||||
import type { ReportedWeaponForMerging } from "~/features/sendouq-match/core/reported-weapons.server";
|
||||
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
@@ -650,8 +650,8 @@ function BottomSection({
|
||||
participatingInTheMatch: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["q", "common"]);
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < 750;
|
||||
const width = useMainContentWidth();
|
||||
const isMobile = width < 650;
|
||||
const isMounted = useIsMounted();
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
.mapButton {
|
||||
background-image: var(--map-image-url);
|
||||
background-size: contain;
|
||||
background-size: cover;
|
||||
height: var(--map-height);
|
||||
width: var(--map-width);
|
||||
border: none;
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-avatar);
|
||||
border-radius: calc(var(--radius-box) + 6px);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
padding-inline-end: var(--s-3);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useUser } from "~/features/auth/core/user";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
|
||||
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
@@ -36,7 +37,11 @@ import type {
|
||||
SQMatchGroupMember,
|
||||
SQOwnGroup,
|
||||
} from "../core/SendouQ.server";
|
||||
import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
|
||||
import {
|
||||
FULL_GROUP_SIZE,
|
||||
IS_Q_LOOKING_MOBILE_BREAKPOINT,
|
||||
SENDOUQ,
|
||||
} from "../q-constants";
|
||||
import { resolveFutureMatchModes } from "../q-utils";
|
||||
import styles from "./GroupCard.module.css";
|
||||
|
||||
@@ -248,10 +253,13 @@ function GroupCardContainer({
|
||||
groupId: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const width = useMainContentWidth();
|
||||
const layout = width < IS_Q_LOOKING_MOBILE_BREAKPOINT ? "mobile" : "desktop";
|
||||
|
||||
// we don't want it to animate
|
||||
if (isOwnGroup) return <>{children}</>;
|
||||
|
||||
return <Flipped flipId={groupId}>{children}</Flipped>;
|
||||
return <Flipped flipId={`${layout}-${groupId}`}>{children}</Flipped>;
|
||||
}
|
||||
|
||||
function GroupMember({
|
||||
|
||||
@@ -17,3 +17,6 @@ export const SENDOUQ_BEST_OF = 7;
|
||||
export const JOIN_CODE_SEARCH_PARAM_KEY = "join";
|
||||
|
||||
export const USER_SKILLS_CACHE_KEY = "user-skills";
|
||||
|
||||
/** Breakpoint (in px) for the main container after which we switch from 3-column layout to tabs */
|
||||
export const IS_Q_LOOKING_MOBILE_BREAKPOINT = 700;
|
||||
|
||||
@@ -19,8 +19,8 @@ import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
@@ -36,7 +36,10 @@ import { GroupLeaver } from "../components/GroupLeaver";
|
||||
import { MemberAdder } from "../components/MemberAdder";
|
||||
import { groupExpiryStatus } from "../core/groups";
|
||||
import { loader } from "../loaders/q.looking.server";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import {
|
||||
FULL_GROUP_SIZE,
|
||||
IS_Q_LOOKING_MOBILE_BREAKPOINT,
|
||||
} from "../q-constants";
|
||||
export { action, loader };
|
||||
|
||||
import styles from "./q.looking.module.css";
|
||||
@@ -209,11 +212,11 @@ function Groups() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const width = useMainContentWidth();
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
const isMobile = width < 750;
|
||||
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
|
||||
const isFullGroup =
|
||||
data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE;
|
||||
|
||||
@@ -410,9 +413,9 @@ function Groups() {
|
||||
}
|
||||
|
||||
function ColumnHeader({ children }: { children: React.ReactNode }) {
|
||||
const { width } = useWindowSize();
|
||||
const width = useMainContentWidth();
|
||||
|
||||
const isMobile = width < 750;
|
||||
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
|
||||
|
||||
if (isMobile) return null;
|
||||
|
||||
|
||||
@@ -248,7 +248,12 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
|
||||
const inactiveFriends: FriendWithActivity[] = [];
|
||||
|
||||
for (const friend of friendRows) {
|
||||
const activity = resolveFriendActivity(friend.id, friend.tournamentName);
|
||||
const activity = resolveFriendActivity(
|
||||
friend.id,
|
||||
friend.tournamentName,
|
||||
friend.teamMemberCount,
|
||||
friend.tournamentMinTeamSize,
|
||||
);
|
||||
|
||||
if (!activity.subtitle) {
|
||||
inactiveFriends.push(friend);
|
||||
@@ -291,7 +296,12 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
|
||||
if (result.length >= MAX_FRIENDS_VISIBLE) break;
|
||||
if (shownIds.has(tm.id)) continue;
|
||||
|
||||
const activity = resolveFriendActivity(tm.id, tm.tournamentName);
|
||||
const activity = resolveFriendActivity(
|
||||
tm.id,
|
||||
tm.tournamentName,
|
||||
tm.teamMemberCount,
|
||||
tm.tournamentMinTeamSize,
|
||||
);
|
||||
if (!activity.subtitle) {
|
||||
inactiveTeamMembers.push(tm);
|
||||
continue;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-box);
|
||||
border-radius: calc(var(--radius-box) + 6px);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
padding-inline-end: var(--s-3);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { Mic, Star, Volume2, VolumeX } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Flipped } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
@@ -11,7 +12,9 @@ import { Image, WeaponImage } from "~/components/Image";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { Pronouns } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { navIconUrl, userPage } from "~/utils/urls";
|
||||
@@ -64,54 +67,74 @@ export function LFGGroupCard({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<section className={styles.group}>
|
||||
{group.teamName ? (
|
||||
<Divider smallText className={styles.teamHeader}>
|
||||
{group.teamAvatarUrl ? (
|
||||
<Avatar size="xxs" url={group.teamAvatarUrl} />
|
||||
) : null}
|
||||
<div className={styles.teamName}>{group.teamName}</div>
|
||||
</Divider>
|
||||
) : null}
|
||||
<div className="stack md">
|
||||
{group.members.map((member) => (
|
||||
<LFGGroupMemberRow
|
||||
key={member.discordId}
|
||||
member={member}
|
||||
showActions={showActions}
|
||||
isOwnGroup={isOwnGroup}
|
||||
<LFGGroupCardContainer groupId={group.id} isOwnGroup={isOwnGroup}>
|
||||
<section className={styles.group}>
|
||||
{group.teamName ? (
|
||||
<Divider smallText className={styles.teamHeader}>
|
||||
{group.teamAvatarUrl ? (
|
||||
<Avatar size="xxs" url={group.teamAvatarUrl} />
|
||||
) : null}
|
||||
<div className={styles.teamName}>{group.teamName}</div>
|
||||
</Divider>
|
||||
) : null}
|
||||
<div className="stack md">
|
||||
{group.members.map((member) => (
|
||||
<LFGGroupMemberRow
|
||||
key={member.discordId}
|
||||
member={member}
|
||||
showActions={showActions}
|
||||
isOwnGroup={isOwnGroup}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isOwnGroup ? (
|
||||
<LFGTeamNote
|
||||
note={group.note}
|
||||
editable={group.usersRole === "OWNER"}
|
||||
isStayAsSub={currentMember?.isStayAsSub ?? false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isOwnGroup ? (
|
||||
<LFGTeamNote
|
||||
note={group.note}
|
||||
editable={group.usersRole === "OWNER"}
|
||||
isStayAsSub={currentMember?.isStayAsSub ?? false}
|
||||
/>
|
||||
) : null}
|
||||
{action &&
|
||||
(ownGroup?.usersRole === "OWNER" || ownGroup?.usersRole === "MANAGER") ? (
|
||||
<fetcher.Form className="stack items-center" method="post">
|
||||
<input type="hidden" name="targetTeamId" value={group.id} />
|
||||
<SubmitButton
|
||||
size="small"
|
||||
variant={action === "UNLIKE" ? "destructive" : "outlined"}
|
||||
_action={action}
|
||||
state={fetcher.state}
|
||||
>
|
||||
{action === "LIKE"
|
||||
? t("q:looking.groups.actions.invite")
|
||||
: action === "ACCEPT"
|
||||
? t("common:actions.accept")
|
||||
: t("q:looking.groups.actions.undo")}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
{action &&
|
||||
(ownGroup?.usersRole === "OWNER" ||
|
||||
ownGroup?.usersRole === "MANAGER") ? (
|
||||
<fetcher.Form className="stack items-center" method="post">
|
||||
<input type="hidden" name="targetTeamId" value={group.id} />
|
||||
<SubmitButton
|
||||
size="small"
|
||||
variant={action === "UNLIKE" ? "destructive" : "outlined"}
|
||||
_action={action}
|
||||
state={fetcher.state}
|
||||
>
|
||||
{action === "LIKE"
|
||||
? t("q:looking.groups.actions.invite")
|
||||
: action === "ACCEPT"
|
||||
? t("common:actions.accept")
|
||||
: t("q:looking.groups.actions.undo")}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
</section>
|
||||
</LFGGroupCardContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function LFGGroupCardContainer({
|
||||
isOwnGroup,
|
||||
groupId,
|
||||
children,
|
||||
}: {
|
||||
isOwnGroup: boolean;
|
||||
groupId: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const width = useMainContentWidth();
|
||||
const layout = width < IS_Q_LOOKING_MOBILE_BREAKPOINT ? "mobile" : "desktop";
|
||||
|
||||
if (isOwnGroup) return <>{children}</>;
|
||||
|
||||
return <Flipped flipId={`${layout}-${groupId}`}>{children}</Flipped>;
|
||||
}
|
||||
|
||||
function LFGGroupMemberRow({
|
||||
member,
|
||||
showActions,
|
||||
@@ -284,6 +307,7 @@ function LFGMemberRoleManager({
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
icon={
|
||||
<Star
|
||||
className={clsx(styles.star, {
|
||||
@@ -367,7 +391,7 @@ function LFGVoiceChatInfo({
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="small"
|
||||
size="miniscule"
|
||||
icon={<Icon className={clsx(styles.vcIcon, color())} />}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -19,10 +19,11 @@ import { WeaponImage } from "~/components/Image";
|
||||
import { Placeholder } from "~/components/Placeholder";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import { LFGGroupCard } from "../components/LFGGroupCard";
|
||||
@@ -69,9 +70,9 @@ function GroupsView({
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const tournament = useTournament();
|
||||
const { width } = useWindowSize();
|
||||
const width = useMainContentWidth();
|
||||
|
||||
const isMobile = width < 750;
|
||||
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
|
||||
|
||||
const ownMemberCount =
|
||||
data.ownGroup?.members.length ?? data.ownTeam?.members.length ?? 0;
|
||||
@@ -373,9 +374,9 @@ function SubCard({ sub }: { sub: SubEntry }) {
|
||||
}
|
||||
|
||||
function ColumnHeader({ children }: { children: React.ReactNode }) {
|
||||
const { width } = useWindowSize();
|
||||
const width = useMainContentWidth();
|
||||
|
||||
const isMobile = width < 750;
|
||||
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
|
||||
|
||||
if (isMobile) return null;
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
delete from "TournamentSub"
|
||||
where
|
||||
"userId" = @userId and
|
||||
"tournamentId" = @tournamentId
|
||||
`);
|
||||
|
||||
export function deleteSub(
|
||||
args: Pick<Tables["TournamentSub"], "userId" | "tournamentId">,
|
||||
) {
|
||||
stm.run(args);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
|
||||
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
@@ -83,7 +83,10 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
userId: data.userId,
|
||||
tournamentId,
|
||||
});
|
||||
deleteSub({ tournamentId, userId: data.userId });
|
||||
await TournamentLFGRepository.leaveLfg({
|
||||
userId: data.userId,
|
||||
tournamentId,
|
||||
});
|
||||
|
||||
ShowcaseTournaments.addToCached({
|
||||
tournamentId,
|
||||
@@ -247,6 +250,10 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
"User has no friend code set",
|
||||
);
|
||||
|
||||
await TournamentLFGRepository.leaveLfg({
|
||||
userId: data.userId,
|
||||
tournamentId,
|
||||
});
|
||||
joinTeam({
|
||||
userId: data.userId,
|
||||
newTeamId: team.id,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
tournamentFromDB,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
|
||||
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
@@ -139,7 +138,6 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
tournamentId,
|
||||
avatarFileName,
|
||||
});
|
||||
deleteSub({ tournamentId, userId: user.id });
|
||||
await SavedCalendarEventRepository.unsave({
|
||||
userId: user.id,
|
||||
tournamentId,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { checkOut } from "./checkOut.server";
|
||||
|
||||
@@ -26,6 +25,7 @@ const deleteMemberStm = sql.prepare(/*sql*/ `
|
||||
and "userId" = @userId
|
||||
`);
|
||||
|
||||
// xxx: migrate to kysely. note: deleteSub was deleted from here
|
||||
export const joinTeam = sql.transaction(
|
||||
({
|
||||
previousTeamId,
|
||||
@@ -33,7 +33,7 @@ export const joinTeam = sql.transaction(
|
||||
newTeamId,
|
||||
userId,
|
||||
inGameName,
|
||||
tournamentId,
|
||||
tournamentId: _tournamentId,
|
||||
checkOutTeam = false,
|
||||
}: {
|
||||
previousTeamId?: number;
|
||||
@@ -50,10 +50,6 @@ export const joinTeam = sql.transaction(
|
||||
deleteMemberStm.run({ tournamentTeamId: previousTeamId ?? null, userId });
|
||||
}
|
||||
|
||||
if (!previousTeamId) {
|
||||
deleteSub({ tournamentId, userId });
|
||||
}
|
||||
|
||||
if (checkOutTeam) {
|
||||
invariant(
|
||||
previousTeamId,
|
||||
|
||||
@@ -2,16 +2,16 @@ import { useState } from "react";
|
||||
import { useEventListener } from "./useEventListener";
|
||||
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
|
||||
|
||||
interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const MOBILE_BREAKPOINT = 600;
|
||||
const DESKTOP_BREAKPOINT = 1000;
|
||||
|
||||
type LayoutSize = "mobile" | "tablet" | "desktop";
|
||||
|
||||
interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useLayoutSize(): LayoutSize {
|
||||
const { width } = useWindowSize();
|
||||
|
||||
@@ -21,7 +21,28 @@ export function useLayoutSize(): LayoutSize {
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
export function useWindowSize(): WindowSize {
|
||||
export function useMainContentWidth() {
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const main = document.querySelector("main");
|
||||
if (!main) return;
|
||||
|
||||
setWidth(main.clientWidth);
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
setWidth(entries[0]?.contentRect.width);
|
||||
});
|
||||
|
||||
observer.observe(main);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
function useWindowSize(): WindowSize {
|
||||
const [windowSize, setWindowSize] = useState<WindowSize>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -593,6 +593,8 @@
|
||||
.top-leaderboard {
|
||||
min-height: 100px;
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 599px) {
|
||||
|
||||
@@ -83,6 +83,10 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.line-through {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.color-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
@@ -620,4 +624,11 @@
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
color: var(--color-text-high);
|
||||
font-style: italic;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "Text",
|
||||
"forms.visibility.title": "Sichtbarkeit",
|
||||
"forms.visibility.public": "Öffentlich",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "Keine Assoziationen verfügkar. Erstelle eine, um in einer kleineren Gruppe nach einem Scrim zu suchen.",
|
||||
"forms.notFoundVisibility.title": "Wenn kein Scrim gefunden wurde bis",
|
||||
"forms.notFoundVisibility.explanation": "Leer lassen, wenn sich die Sichtbarkeit deines Posts nicht mit der Zeit ändern soll.",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "Text",
|
||||
"forms.visibility.title": "Visibility",
|
||||
"forms.visibility.public": "Public",
|
||||
"forms.visibility.friends": "Friends only",
|
||||
"forms.visibility.noneAvailable": "No associations available. Create one to look for a scrim in a smaller group.",
|
||||
"forms.notFoundVisibility.title": "If not found by",
|
||||
"forms.notFoundVisibility.explanation": "Leave blank if you don't want the visibility of your post to change over time",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "Texto",
|
||||
"forms.visibility.title": "Visibilidad",
|
||||
"forms.visibility.public": "Pública",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "No hay asociaciones disponibles. Crea una para buscar un scrim en un grupo más reducido.",
|
||||
"forms.notFoundVisibility.title": "Si no se encuentra antes de",
|
||||
"forms.notFoundVisibility.explanation": "Déjalo en blanco si no quieres que la visibilidad de tu publicación cambie con el tiempo",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "Texte",
|
||||
"forms.visibility.title": "Visibilité",
|
||||
"forms.visibility.public": "Public",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "Aucun association disponible. Créez-en un pour rechercher un scrim dans un groupe plus petit.",
|
||||
"forms.notFoundVisibility.title": "S'il n'est pas trouvé par",
|
||||
"forms.notFoundVisibility.explanation": "Laissez vide si vous ne souhaitez pas que la visibilité de votre post change au fil du temps",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"forms.text.title": "",
|
||||
"forms.visibility.title": "",
|
||||
"forms.visibility.public": "",
|
||||
"forms.visibility.friends": "",
|
||||
"forms.visibility.noneAvailable": "",
|
||||
"forms.notFoundVisibility.title": "",
|
||||
"forms.notFoundVisibility.explanation": "",
|
||||
|
||||
Reference in New Issue
Block a user