diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css
index 67e47f895..4f3d5f6f0 100644
--- a/app/components/MobileNav.module.css
+++ b/app/components/MobileNav.module.css
@@ -3,7 +3,7 @@
bottom: 0;
left: 0;
right: 0;
- z-index: 10;
+ z-index: 50;
}
@media screen and (min-width: 600px) {
@@ -63,6 +63,14 @@
height: 24px;
}
+.sideNavEmpty {
+ font-size: var(--font-2xs);
+ color: var(--color-text-high);
+ padding: var(--s-1) var(--s-2);
+ text-align: center;
+ font-style: italic;
+}
+
.notificationDot {
position: absolute;
top: -2px;
@@ -236,66 +244,6 @@
margin: 0;
}
-.streamItem {
- display: flex;
- align-items: center;
- gap: var(--s-2);
- padding: var(--s-1) var(--s-2);
- border-radius: var(--radius-field);
-}
-
-.streamItem:hover {
- background-color: var(--color-bg-higher);
-}
-
-.streamItemImage {
- width: 32px;
- height: 32px;
- border-radius: var(--radius-field);
- object-fit: cover;
- flex-shrink: 0;
-}
-
-.streamItemContent {
- display: flex;
- flex-direction: column;
- gap: var(--s-0-5);
- min-width: 0;
- flex: 1;
-}
-
-.streamItemName {
- font-size: var(--font-xs);
- color: var(--color-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.streamItemMeta {
- display: flex;
- align-items: center;
- gap: var(--s-2);
-}
-
-.streamItemSubtitle {
- font-size: var(--font-2xs);
- color: var(--color-text-high);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.streamItemBadge {
- font-size: var(--font-2xs);
- font-weight: var(--weight-semi);
- color: var(--color-text-inverse);
- background-color: var(--color-text-accent);
- padding: 0 var(--s-1);
- border-radius: var(--radius-field);
- flex-shrink: 0;
-}
-
.navGrid {
list-style: none;
margin: 0;
@@ -403,3 +351,14 @@
text-transform: uppercase;
letter-spacing: 0.05em;
}
+
+.streamXpSubtitle {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.streamXpIcon {
+ width: 14px;
+ height: 14px;
+}
diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx
index bfd443670..8e6db6863 100644
--- a/app/components/MobileNav.tsx
+++ b/app/components/MobileNav.tsx
@@ -6,6 +6,7 @@ import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useUser } from "~/features/auth/core/user";
+import { FriendMenu } from "~/features/friends/components/FriendMenu";
import type { RootLoaderData } from "~/root";
import {
FRIENDS_PAGE,
@@ -254,28 +255,36 @@ function MenuOverlay({
{t("front:sideNav.streams")}
+ {streams.length === 0 ? (
+
+ {t("front:sideNav.noStreams")}
+
+ ) : null}
{streams.map((stream) => (
- -
-
-
-
{stream.name}
-
- {stream.subtitle ? (
-
- {stream.subtitle}
-
- ) : null}
- {stream.startsAt < Date.now() ? (
- LIVE
- ) : null}
-
-
-
+
+
+ {stream.peakXp}
+
+ ) : (
+ stream.subtitle
+ )
+ }
+ onClick={onClose}
+ >
+ {stream.name}
+
))}
@@ -317,23 +326,21 @@ function FriendsPanel({
onClose: () => void;
}) {
const { t } = useTranslation(["front", "common"]);
+ const user = useUser();
return (
- {friends.map((friend) => (
-
- {friend.name}
-
- ))}
+ {friends.length > 0 ? (
+ friends.map((friend) => (
+
+ ))
+ ) : (
+
+ {user
+ ? t("front:sideNav.friends.noFriends")
+ : t("front:sideNav.friends.notLoggedIn")}
+
+ )}
;
- subtitle?: string | null;
+ subtitle?: React.ReactNode;
badge?: string | null;
badgeVariant?: "default" | "warning";
}) {
diff --git a/app/components/icons/LogOut.tsx b/app/components/icons/LogOut.tsx
deleted file mode 100644
index 7f758a8ae..000000000
--- a/app/components/icons/LogOut.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-export function LogOutIcon({ className }: { className?: string }) {
- return (
-
- );
-}
diff --git a/app/components/layout/UserItem.tsx b/app/components/layout/UserItem.tsx
deleted file mode 100644
index f8d99f206..000000000
--- a/app/components/layout/UserItem.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import clsx from "clsx";
-import { LogIn } from "lucide-react";
-import { useTranslation } from "react-i18next";
-import { Link } from "react-router";
-import { useUser } from "~/features/auth/core/user";
-import { userPage } from "~/utils/urls";
-import { Avatar } from "../Avatar";
-import { LogInButtonContainer } from "./LogInButtonContainer";
-import styles from "./UserItem.module.css";
-
-export function UserItem({ className }: { className?: string }) {
- const { t } = useTranslation();
- const user = useUser();
-
- if (user) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
- );
-}
diff --git a/app/components/layout/WeaponSearch.tsx b/app/components/layout/WeaponSearch.tsx
index 75b7aead3..b9cdba4fe 100644
--- a/app/components/layout/WeaponSearch.tsx
+++ b/app/components/layout/WeaponSearch.tsx
@@ -21,7 +21,6 @@ import {
} from "~/modules/in-game-lists/weapon-ids";
import {
ANALYZER_URL,
- artPage,
LFG_PAGE,
mainWeaponImageUrl,
mySlugify,
@@ -90,7 +89,7 @@ export function getWeaponDestinationUrl(
stats: weaponBuildStatsPage(weapon.slug),
analyzer: `${ANALYZER_URL}?weapon=${weapon.id}`,
vods: `${VODS_PAGE}?weapon=${weapon.id}`,
- art: artPage(weapon.slug),
+ art: `/art?tab=showcase&tag=${encodeURIComponent(weapon.name.toLowerCase())}`,
lfg: `${LFG_PAGE}?weapon=${weapon.id}`,
};
diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css
index 958d244c4..c0c76c65a 100644
--- a/app/components/layout/index.module.css
+++ b/app/components/layout/index.module.css
@@ -3,6 +3,17 @@
min-width: 0;
}
+.streamXpSubtitle {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.streamXpIcon {
+ width: 14px;
+ height: 14px;
+}
+
.header {
display: flex;
width: 100%;
@@ -33,10 +44,10 @@
justify-content: center;
width: 40px;
height: 36px;
- background-color: var(--color-accent);
+ background-color: var(--color-text-accent);
border-radius: var(--radius-field);
font-weight: var(--weight-bold);
- color: var(--color-text);
+ color: var(--color-text-inverse);
text-decoration: none;
flex-shrink: 0;
transition: background-color 0.2s;
@@ -55,11 +66,6 @@
top: 5px;
}
-.siteLogo:hover {
- background-color: var(--color-accent-high);
- color: var(--color-text-inverse);
-}
-
.siteLogo:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
@@ -117,9 +123,11 @@
}
.sideNavEmpty {
- font-size: var(--font-xs);
+ font-size: var(--font-2xs);
color: var(--color-text-high);
padding: var(--s-1) var(--s-2);
+ margin: 0 auto;
+ font-style: italic;
}
.viewAllLink {
@@ -149,6 +157,18 @@
}
}
+.headerCollapsedBreadcrumbs {
+ display: none;
+ min-width: 0;
+}
+
+@media screen and (min-width: 600px) {
+ .headerCollapsedBreadcrumbs {
+ display: flex;
+ min-width: 0;
+ }
+}
+
.mobileLogo {
display: none;
}
diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx
index 0b9dbaf27..340bd3d94 100644
--- a/app/components/layout/index.tsx
+++ b/app/components/layout/index.tsx
@@ -183,6 +183,7 @@ export function Layout({
const navOffset = useNavOffset();
const isMounted = useIsMounted();
+ const user = useUser();
const sidebarData = data?.sidebar;
const events = sidebarData?.events ?? [];
const matchStatus = sidebarData?.matchStatus;
@@ -264,21 +265,34 @@ export function Layout({
}
action={
-
- {t("common:actions.viewAll")}
-
-
+ user ? (
+
+ {t("common:actions.viewAll")}
+
+
+ ) : null
}
>
{t("front:sideNav.friends")}
- {friends.map((friend) => (
-
- ))}
+ {friends.length > 0 ? (
+ friends.map((friend) => )
+ ) : (
+
+ {user
+ ? t("front:sideNav.friends.noFriends")
+ : t("front:sideNav.friends.notLoggedIn")}
+
+ )}
}>
{t("front:sideNav.streams")}
+ {streams.length === 0 ? (
+
+ {t("front:sideNav.noStreams")}
+
+ ) : null}
{streams.map((stream) => {
const startsAtDate = databaseTimestampToDate(stream.startsAt);
@@ -289,12 +303,25 @@ export function Layout({
imageUrl={stream.imageUrl}
overlayIconUrl={stream.overlayIconUrl}
subtitle={
- isMounted
- ? formatDistanceToNow(startsAtDate, {
- addSuffix: true,
- language: i18n.language as LanguageCode,
- })
- : ""
+ stream.peakXp ? (
+
+
+ {stream.peakXp}
+
+ ) : stream.subtitle ? (
+ stream.subtitle
+ ) : isMounted ? (
+ formatDistanceToNow(startsAtDate, {
+ addSuffix: true,
+ language: i18n.language as LanguageCode,
+ })
+ ) : (
+ ""
+ )
}
badge={
isMounted && startsAtDate.getTime() < Date.now()
@@ -316,6 +343,11 @@ export function Layout({
}}
>
+ {sideNavCollapsed ? (
+
+
+
+ ) : null}
setSideNavCollapsed(!sideNavCollapsed)}
/>
@@ -487,10 +519,17 @@ function SideNavUserPanel() {
}
return (
-
- }>
- {t("header.login.discord")}
-
-
+ <>
+
+ }>
+ {t("header.login.discord")}
+
+
+
+
+
+
+
+ >
);
}
diff --git a/app/features/core/streams/streams.server.ts b/app/features/core/streams/streams.server.ts
index 202693b27..7a3753118 100644
--- a/app/features/core/streams/streams.server.ts
+++ b/app/features/core/streams/streams.server.ts
@@ -1,17 +1,15 @@
-import { cachified } from "@epic-web/cachified";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { Status } from "~/modules/brackets-model";
-import { cache, ttl } from "~/utils/cache.server";
+import { cache } from "~/utils/cache.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { tournamentStreamsPage } from "~/utils/urls";
-const FIVE_MINUTES = 5 * 60 * 1000;
-const MAX_STREAMS = 5;
+export const COMBINED_STREAMS_KEY = "combined-streams";
-export function clearLiveStreamsCache() {
- cache.delete("live-tournament-streams");
+export function clearCombinedStreamsCache() {
+ cache.delete(COMBINED_STREAMS_KEY);
}
export type SidebarStream = {
@@ -23,34 +21,28 @@ export type SidebarStream = {
subtitle: string;
startsAt: number;
tier: TournamentTierNumber | null;
+ peakXp?: number;
+ twitchUsername?: string;
};
-export function getLiveTournamentStreams(): Promise {
- return cachified({
- key: "live-tournament-streams",
- cache,
- ttl: ttl(FIVE_MINUTES),
- async getFreshValue() {
- const streams: SidebarStream[] = [];
+export function getLiveTournamentStreams(): SidebarStream[] {
+ const streams: SidebarStream[] = [];
- for (const tournament of RunningTournaments.all) {
- streams.push({
- id: tournament.ctx.id,
- name: tournament.ctx.name,
- imageUrl: tournament.ctx.logoUrl,
- url: tournamentStreamsPage(tournament.ctx.id),
- subtitle: deriveCurrentRound(tournament),
- startsAt: dateToDatabaseTimestamp(tournament.ctx.startTime),
- tier: tournament.ctx.tier,
- });
- }
+ for (const tournament of RunningTournaments.all) {
+ streams.push({
+ id: tournament.ctx.id,
+ name: tournament.ctx.name,
+ imageUrl: tournament.ctx.logoUrl,
+ url: tournamentStreamsPage(tournament.ctx.id),
+ subtitle: deriveCurrentRound(tournament),
+ startsAt: dateToDatabaseTimestamp(tournament.ctx.startTime),
+ tier: tournament.ctx.tier,
+ });
+ }
- return streams.sort(sortByTierAscending).slice(0, MAX_STREAMS);
- },
- });
+ return streams;
}
-// xxx: this could be moved to Tournament class
// xxx: not always reporting furthest round
function deriveCurrentRound(tournament: Tournament): string {
for (const bracket of tournament.brackets) {
@@ -76,10 +68,3 @@ function deriveCurrentRound(tournament: Tournament): string {
return "";
}
-
-function sortByTierAscending(a: SidebarStream, b: SidebarStream): number {
- if (a.tier === null && b.tier === null) return 0;
- if (a.tier === null) return 1;
- if (b.tier === null) return -1;
- return a.tier - b.tier;
-}
diff --git a/app/features/friends/FriendRepository.server.ts b/app/features/friends/FriendRepository.server.ts
index dcf66efe4..baa501dfb 100644
--- a/app/features/friends/FriendRepository.server.ts
+++ b/app/features/friends/FriendRepository.server.ts
@@ -1,55 +1,97 @@
import { db } from "~/db/sql";
export async function findByUserIdWithActivity(userId: number) {
- const rows = await db
- .selectFrom("Friendship")
- .innerJoin("User", (join) =>
- join.on((eb) =>
+ const [friendRows, teamMemberRows] = await Promise.all([
+ 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")),
+ ]),
+ ]),
+ ),
+ )
+ .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.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();
+ )
+ .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(),
+ ]);
- return rows;
+ return [
+ ...friendRows,
+ ...teamMemberRows.map((row) => ({
+ ...row,
+ friendshipId: null as number | null,
+ friendshipCreatedAt: null as number | null,
+ })),
+ ];
}
export async function findPendingSentRequests(senderId: number) {
diff --git a/app/features/friends/components/FriendMenu.tsx b/app/features/friends/components/FriendMenu.tsx
index 4fb3182ae..20d58ff3c 100644
--- a/app/features/friends/components/FriendMenu.tsx
+++ b/app/features/friends/components/FriendMenu.tsx
@@ -11,7 +11,7 @@ import {
} from "~/components/elements/Menu";
import { ListButton } from "~/components/SideNav";
import { databaseTimestampToDate } from "~/utils/dates";
-import { SENDOUQ_LOOKING_PAGE, tournamentPage } from "~/utils/urls";
+import { SENDOUQ_LOOKING_PAGE, tournamentSubsPage } from "~/utils/urls";
export function FriendMenu({
discordId,
@@ -23,6 +23,7 @@ export function FriendMenu({
tournamentId,
friendshipId,
friendshipCreatedAt,
+ onNavigate,
}: {
discordId: string;
discordAvatar: string | null;
@@ -33,6 +34,7 @@ export function FriendMenu({
tournamentId: number | null;
friendshipId?: number;
friendshipCreatedAt?: number | null;
+ onNavigate?: () => void;
}) {
const { t } = useTranslation(["common", "friends"]);
const fetcher = useFetcher();
@@ -60,11 +62,15 @@ export function FriendMenu({
}
>
- }>
+ } onAction={onNavigate}>
{t("friends:friendsList.viewUserPage")}
{activityHref ? (
- }>
+ }
+ onAction={onNavigate}
+ >
{activityHref.isSendouQ
? t("friends:friendsList.joinSendouQ")
: t("friends:friendsList.viewTournament")}
@@ -127,7 +133,7 @@ function resolveActivityHref(friend: {
}
if (friend.tournamentId) {
- return { url: tournamentPage(friend.tournamentId), isSendouQ: false };
+ return { url: tournamentSubsPage(friend.tournamentId), isSendouQ: false };
}
return null;
diff --git a/app/features/friends/loaders/friends.server.ts b/app/features/friends/loaders/friends.server.ts
index 4e7662531..041723a44 100644
--- a/app/features/friends/loaders/friends.server.ts
+++ b/app/features/friends/loaders/friends.server.ts
@@ -16,28 +16,29 @@ export const loader = async () => {
FriendRepository.findPendingReceivedRequests(user.id),
]);
- // xxx: why is this needed? shouldn't the query be doing this already?
- const uniqueFriends = R.uniqueBy(friendsWithActivity, (f) => f.id);
+ const unique = R.uniqueBy(friendsWithActivity, (f) => f.id);
- const friends = uniqueFriends.map((friend) => {
- const activity = resolveFriendActivity(friend.id, friend.tournamentName);
+ const friends = unique
+ .filter((f) => f.friendshipId !== null)
+ .map((friend) => {
+ const activity = resolveFriendActivity(friend.id, friend.tournamentName);
- return {
- id: friend.id,
- friendshipId: friend.friendshipId,
- username: friend.username,
- discordId: friend.discordId,
- discordAvatar: friend.discordAvatar,
- url: userPage({
+ return {
+ id: friend.id,
+ friendshipId: friend.friendshipId as number,
+ username: friend.username,
discordId: friend.discordId,
- customUrl: friend.customUrl,
- }),
- subtitle: activity.subtitle,
- badge: activity.badge,
- tournamentId: friend.tournamentId,
- friendshipCreatedAt: friend.friendshipCreatedAt,
- };
- });
+ discordAvatar: friend.discordAvatar,
+ url: userPage({
+ discordId: friend.discordId,
+ customUrl: friend.customUrl,
+ }),
+ subtitle: activity.subtitle,
+ badge: activity.badge,
+ tournamentId: friend.tournamentId,
+ friendshipCreatedAt: friend.friendshipCreatedAt,
+ };
+ });
friends.sort((a, b) => {
const aActive = a.subtitle ? 1 : 0;
@@ -47,8 +48,35 @@ export const loader = async () => {
return (b.friendshipCreatedAt ?? 0) - (a.friendshipCreatedAt ?? 0);
});
+ const teamMembers = unique
+ .filter((f) => f.friendshipId === null)
+ .map((tm) => {
+ const activity = resolveFriendActivity(tm.id, tm.tournamentName);
+
+ return {
+ id: tm.id,
+ username: tm.username,
+ discordId: tm.discordId,
+ discordAvatar: tm.discordAvatar,
+ url: userPage({
+ discordId: tm.discordId,
+ customUrl: tm.customUrl,
+ }),
+ subtitle: activity.subtitle,
+ badge: activity.badge,
+ tournamentId: tm.tournamentId,
+ };
+ });
+
+ teamMembers.sort((a, b) => {
+ const aActive = a.subtitle ? 1 : 0;
+ const bActive = b.subtitle ? 1 : 0;
+ return bActive - aActive;
+ });
+
return {
friends,
+ teamMembers,
incomingRequests: incomingRequests.map((req) => ({
id: req.id,
sender: {
diff --git a/app/features/friends/routes/friends.module.css b/app/features/friends/routes/friends.module.css
index 92fc366ab..499528af3 100644
--- a/app/features/friends/routes/friends.module.css
+++ b/app/features/friends/routes/friends.module.css
@@ -12,3 +12,25 @@
gap: var(--s-2);
font-weight: var(--weight-semi);
}
+
+.friendsListHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--s-2);
+}
+
+.filterRadio {
+ padding: var(--s-1) var(--s-2);
+ border-radius: var(--radius-field);
+ cursor: pointer;
+ color: var(--color-text-high);
+ font-weight: var(--weight-semi);
+ font-size: var(--font-3xs);
+}
+
+.filterRadioSelected {
+ background-color: var(--color-bg-higher);
+ color: var(--color-text);
+}
diff --git a/app/features/friends/routes/friends.tsx b/app/features/friends/routes/friends.tsx
index ba3b8f223..8f81c5b69 100644
--- a/app/features/friends/routes/friends.tsx
+++ b/app/features/friends/routes/friends.tsx
@@ -1,3 +1,6 @@
+import clsx from "clsx";
+import { useState } from "react";
+import { Radio, RadioGroup } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Form, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
@@ -17,6 +20,8 @@ export const handle: SendouRouteHandle = {
i18n: ["friends"],
};
+type ViewFilter = "friends" | "team" | "all";
+
export default function FriendsPage() {
const data = useLoaderData();
@@ -136,19 +141,75 @@ function PendingRequestsSection() {
function FriendsListSection() {
const { t } = useTranslation(["common", "friends"]);
const data = useLoaderData();
+ const [filter, setFilter] = useState("friends");
+
+ const viewLabels: Record = {
+ friends: t("friends:view.friends"),
+ team: t("friends:view.teamMembers"),
+ all: t("friends:view.all"),
+ };
+
+ const shownItems = resolveShownItems(filter, data);
+ const emptyKey =
+ filter === "team"
+ ? "friends:teamMembers.empty"
+ : "friends:friendsList.empty";
return (
- {t("friends:friendsList.title")}
- {data.friends.length === 0 ? (
- {t("friends:friendsList.empty")}
+
+
{t("friends:friendsList.title")}
+ setFilter(v as ViewFilter)}
+ aria-label={t("friends:view.label")}
+ orientation="horizontal"
+ className="stack horizontal xs"
+ >
+ {(["friends", "team", "all"] as const).map((value) => (
+
+ {({ isSelected }) => (
+
+ {viewLabels[value]}
+
+ )}
+
+ ))}
+
+
+ {shownItems.length === 0 ? (
+ {t(emptyKey)}
) : (
- {data.friends.map((friend) => (
-
+ {shownItems.map((item) => (
+
))}
)}
);
}
+
+function resolveShownItems(
+ filter: ViewFilter,
+ data: Awaited>,
+) {
+ if (filter === "friends") return data.friends;
+ if (filter === "team") return data.teamMembers;
+
+ const friendIds = new Set(data.friends.map((f) => f.id));
+ const combined = [
+ ...data.friends,
+ ...data.teamMembers.filter((tm) => !friendIds.has(tm.id)),
+ ];
+
+ return combined.sort((a, b) => {
+ const aActive = a.subtitle ? 1 : 0;
+ const bActive = b.subtitle ? 1 : 0;
+ return bActive - aActive;
+ });
+}
diff --git a/app/features/lfg/components/LFGPost.module.css b/app/features/lfg/components/LFGPost.module.css
index 10b3898e0..db327f868 100644
--- a/app/features/lfg/components/LFGPost.module.css
+++ b/app/features/lfg/components/LFGPost.module.css
@@ -86,7 +86,7 @@
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
border-radius: var(--radius-selector);
- background-color: var(--color-bg-higher);
+ background-color: var(--color-bg-high);
padding: 0 var(--s-2);
height: var(--selector-size-sm);
display: flex;
diff --git a/app/features/lfg/routes/lfg.tsx b/app/features/lfg/routes/lfg.tsx
index bbcb5b0aa..f625ba30d 100644
--- a/app/features/lfg/routes/lfg.tsx
+++ b/app/features/lfg/routes/lfg.tsx
@@ -96,6 +96,7 @@ export default function LFGPage() {
const activeFilterCount = countActiveFilters(filters);
+ // xxx: undo changes that introduce the panel here
return (
[],
@@ -12,3 +14,24 @@ export function replaceAll(
}
});
}
+
+export function findXRankStreams() {
+ return db
+ .selectFrom("LiveStream")
+ .innerJoin("User", "User.twitch", "LiveStream.twitch")
+ .innerJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id")
+ .where(
+ "SplatoonPlayer.peakXp",
+ ">=",
+ StreamRanking.minXpForStreamToBeShown(),
+ )
+ .where("LiveStream.twitch", "is not", null)
+ .select([
+ ...COMMON_USER_FIELDS,
+ "SplatoonPlayer.peakXp",
+ "LiveStream.viewerCount",
+ "LiveStream.thumbnailUrl",
+ "LiveStream.twitch as twitchUsername",
+ ])
+ .execute();
+}
diff --git a/app/features/scrims/ScrimPostRepository.server.ts b/app/features/scrims/ScrimPostRepository.server.ts
index 0c504ea6e..03c70b359 100644
--- a/app/features/scrims/ScrimPostRepository.server.ts
+++ b/app/features/scrims/ScrimPostRepository.server.ts
@@ -417,39 +417,59 @@ export async function findUserScrims(userId: number): Promise {
const rows = await baseFindQuery
.where("ScrimPost.canceledAt", "is", null)
.where("ScrimPost.at", ">=", now)
+ .where((eb) =>
+ eb.or([
+ eb.exists(
+ eb
+ .selectFrom("ScrimPostUser")
+ .select("ScrimPostUser.scrimPostId")
+ .whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id")
+ .where("ScrimPostUser.userId", "=", userId),
+ ),
+ eb.exists(
+ eb
+ .selectFrom("ScrimPostRequest")
+ .innerJoin(
+ "ScrimPostRequestUser",
+ "ScrimPostRequestUser.scrimPostRequestId",
+ "ScrimPostRequest.id",
+ )
+ .select("ScrimPostRequest.scrimPostId")
+ .whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
+ .where("ScrimPostRequestUser.userId", "=", userId),
+ ),
+ ]),
+ )
.orderBy("ScrimPost.at", "asc")
.execute();
- return rows
- .map(mapDBRowToScrimPost)
- .filter((post) => Scrim.isParticipating(post, userId))
- .map((post) => {
- const isAccepted = Scrim.isAccepted(post);
-
- if (!isAccepted) {
- return {
- id: post.id,
- at: post.at,
- opponentName: null,
- opponentAvatarUrl: null,
- isAccepted: false,
- };
- }
-
- const userIsInPost = post.users.some((u) => u.id === userId);
- const opponent = userIsInPost
- ? post.requests[0]
- : { team: post.team, users: post.users };
- const opponentTeam = opponent?.team;
- const opponentOwner = opponent?.users.find((u) => u.isOwner);
+ return rows.map(mapDBRowToScrimPost).map((post) => {
+ const isAccepted = Scrim.isAccepted(post);
+ if (!isAccepted) {
return {
id: post.id,
at: post.at,
- opponentName: opponentTeam?.name ?? null,
- opponentAvatarUrl:
- opponentTeam?.avatarUrl ?? opponentOwner?.discordAvatar ?? null,
- isAccepted: true,
+ opponentName: null,
+ opponentAvatarUrl: null,
+ isAccepted: false,
};
- });
+ }
+
+ const userIsInPost = post.users.some((u) => u.id === userId);
+ const opponent = userIsInPost
+ ? post.requests[0]
+ : { team: post.team, users: post.users };
+ const opponentTeam = opponent?.team;
+ const opponentOwner = opponent?.users.find((u) => u.isOwner);
+
+ return {
+ id: post.id,
+ at: post.at,
+ opponentName: opponentTeam?.name ?? null,
+ opponentAvatarUrl:
+ opponentTeam?.avatarUrl ?? opponentOwner?.discordAvatar ?? null,
+ isAccepted: true,
+ };
+ });
}
diff --git a/app/features/sendouq-streams/core/streams.server.ts b/app/features/sendouq-streams/core/streams.server.ts
index 60c48ce84..6511f0e94 100644
--- a/app/features/sendouq-streams/core/streams.server.ts
+++ b/app/features/sendouq-streams/core/streams.server.ts
@@ -1,6 +1,9 @@
import cachified from "@epic-web/cachified";
import * as R from "remeda";
-import type { SidebarStream } from "~/features/core/streams/streams.server";
+import {
+ COMBINED_STREAMS_KEY,
+ type SidebarStream,
+} from "~/features/core/streams/streams.server";
import {
cachedFullUserLeaderboard,
type UserLeaderboardWithAdditionsItem,
@@ -60,6 +63,7 @@ export function cachedStreams() {
export function refreshStreamsCache() {
cache.delete(SENDOUQ_STREAMS_KEY);
+ cache.delete(COMBINED_STREAMS_KEY);
void cachedStreams().catch((err) =>
logger.error(`Failed to refresh cache: ${err}`),
);
@@ -101,12 +105,20 @@ function streamedMatches({
});
}
-export async function getSendouQSidebarStreams(): Promise {
+export type SendouQSidebarEntry = {
+ sidebarStream: SidebarStream;
+ tier: { name: TierName; isPlus: boolean } | null;
+ twitchUsernames: string[];
+};
+
+export async function getSendouQSidebarStreams(): Promise<
+ SendouQSidebarEntry[]
+> {
const streams = await cachedStreams();
const matchIdToStream = R.groupBy(streams, (s) => s.match.id);
- const sidebarStreams: SidebarStream[] = [];
+ const entries: SendouQSidebarEntry[] = [];
for (const [matchIdStr, matchStreams] of Object.entries(matchIdToStream)) {
const matchId = Number(matchIdStr);
@@ -115,25 +127,35 @@ export async function getSendouQSidebarStreams(): Promise {
const matchGroups = SendouQ.groups.filter((g) => g.matchId === matchId);
const averageTier = calculateAverageTierForMatch(matchGroups);
- sidebarStreams.push({
- id: -matchId,
- name: `Match #${matchId}`,
- imageUrl: averageTier
- ? `${tierImageUrl(averageTier.name)}.png`
- : `${navIconUrl("sendouq")}.png`,
- overlayIconUrl: averageTier ? `${navIconUrl("sendouq")}.png` : undefined,
- url: SENDOUQ_STREAMS_PAGE,
- subtitle: averageTier
- ? `${averageTier.name}${averageTier.isPlus ? "+" : ""}`
- : "",
- startsAt: firstStream.match.createdAt,
- tier: null,
+ const twitchUsernames = matchStreams
+ .map((s) => s.stream.twitchUserName)
+ .filter((t): t is string => t !== null);
+
+ entries.push({
+ sidebarStream: {
+ id: -matchId,
+ name: `Match #${matchId}`,
+ imageUrl: averageTier
+ ? `${tierImageUrl(averageTier.name)}.png`
+ : `${navIconUrl("sendouq")}.png`,
+ overlayIconUrl: averageTier
+ ? `${navIconUrl("sendouq")}.png`
+ : undefined,
+ url: SENDOUQ_STREAMS_PAGE,
+ subtitle: averageTier
+ ? `${averageTier.name}${averageTier.isPlus ? "+" : ""}`
+ : "",
+ startsAt: firstStream.match.createdAt,
+ tier: null,
+ },
+ tier: averageTier,
+ twitchUsernames,
});
}
- return sidebarStreams.sort((a, b) => {
- const aTierIndex = getTierIndexFromSubtitle(a.subtitle);
- const bTierIndex = getTierIndexFromSubtitle(b.subtitle);
+ return entries.sort((a, b) => {
+ const aTierIndex = getTierIndexFromSubtitle(a.sidebarStream.subtitle);
+ const bTierIndex = getTierIndexFromSubtitle(b.sidebarStream.subtitle);
return aTierIndex - bTierIndex;
});
}
diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx
index 3a13f7db5..896dc0bf3 100644
--- a/app/features/settings/routes/settings.tsx
+++ b/app/features/settings/routes/settings.tsx
@@ -53,7 +53,21 @@ export default function SettingsPage() {
return (
-
{t("common:pages.settings")}
+
+
{t("common:pages.settings")}
+ {user ? (
+
+ ) : null}
+
{t("common:settings.locales")}
@@ -69,11 +83,6 @@ export default function SettingsPage() {
{({ FormField }) =>
}
) : null}
-
- {t("common:settings.theme")}
-
-
-
{user ? (
<>
@@ -111,18 +120,13 @@ export default function SettingsPage() {
{({ FormField }) => }
-
>
) : null}
+
+ {t("common:settings.theme")}
+
+
+
);
diff --git a/app/features/sidebar/core/StreamRanking.test.ts b/app/features/sidebar/core/StreamRanking.test.ts
new file mode 100644
index 000000000..63b85c761
--- /dev/null
+++ b/app/features/sidebar/core/StreamRanking.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import * as StreamRanking from "./StreamRanking";
+
+describe("StreamRanking.sendouQTierToScore", () => {
+ it("LEVIATHAN+ scores 1", () => {
+ expect(
+ StreamRanking.sendouQTierToScore({ name: "LEVIATHAN", isPlus: true }),
+ ).toBe(1);
+ });
+
+ it("PLATINUM+ scores 5", () => {
+ expect(
+ StreamRanking.sendouQTierToScore({ name: "PLATINUM", isPlus: true }),
+ ).toBe(5);
+ });
+
+ it("IRON & SILVER+ scores 9 (capped)", () => {
+ expect(
+ StreamRanking.sendouQTierToScore({ name: "SILVER", isPlus: true }),
+ ).toBe(9);
+
+ expect(
+ StreamRanking.sendouQTierToScore({ name: "IRON", isPlus: false }),
+ ).toBe(9);
+ });
+});
+
+describe("StreamRanking.xpToScore", () => {
+ it("returns null for XP below 3000", () => {
+ expect(StreamRanking.xpToScore(2999)).toBeNull();
+ expect(StreamRanking.xpToScore(0)).toBeNull();
+ });
+
+ it("3000 XP scores 9", () => {
+ expect(StreamRanking.xpToScore(3000)).toBe(9);
+ });
+
+ it("3200 XP scores 8", () => {
+ expect(StreamRanking.xpToScore(3200)).toBe(8);
+ });
+
+ it("3400 XP scores 7", () => {
+ expect(StreamRanking.xpToScore(3400)).toBe(7);
+ });
+
+ it("3800 XP scores 5 (X rank minimum)", () => {
+ expect(StreamRanking.xpToScore(3800)).toBe(5);
+ });
+
+ it("XP above 3800 is capped at score 5", () => {
+ expect(StreamRanking.xpToScore(4200)).toBe(5);
+ expect(StreamRanking.xpToScore(4600)).toBe(5);
+ expect(StreamRanking.xpToScore(9999)).toBe(5);
+ });
+});
diff --git a/app/features/sidebar/core/StreamRanking.ts b/app/features/sidebar/core/StreamRanking.ts
new file mode 100644
index 000000000..3ed338a7d
--- /dev/null
+++ b/app/features/sidebar/core/StreamRanking.ts
@@ -0,0 +1,47 @@
+import type { SidebarStream } from "~/features/core/streams/streams.server";
+import { TIERS, type TierName } from "~/features/mmr/mmr-constants";
+import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
+
+type RankedStream = { stream: SidebarStream; score: number };
+
+export function rank(
+ streams: RankedStream[],
+ maxStreams: number,
+): SidebarStream[] {
+ return streams
+ .sort((a, b) => a.score - b.score || a.stream.startsAt - b.stream.startsAt)
+ .slice(0, maxStreams)
+ .map((rs) => rs.stream);
+}
+
+export function tournamentTierToScore(
+ tier: TournamentTierNumber | null,
+): number {
+ return tier ?? 9;
+}
+
+export function sendouQTierToScore(tier: {
+ name: TierName;
+ isPlus: boolean;
+}): number {
+ const baseIndex = TIERS.findIndex((t) => t.name === tier.name);
+ if (baseIndex === -1) return 9;
+ return Math.min(9, baseIndex * 2 + (tier.isPlus ? 1 : 2));
+}
+
+const X_RANK_SCORES = [
+ [3800, 5],
+ [3600, 6],
+ [3400, 7],
+ [3200, 8],
+ [3000, 9],
+] as const;
+
+export function minXpForStreamToBeShown(): number {
+ return X_RANK_SCORES.at(-1)?.[0] ?? 3_000;
+}
+
+export function xpToScore(peakXp: number): number | null {
+ const entry = X_RANK_SCORES.find(([minXp]) => peakXp >= minXp);
+ return entry ? entry[1] : null;
+}
diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts
index dbb214ea2..dcba14dfe 100644
--- a/app/features/sidebar/core/sidebar.server.ts
+++ b/app/features/sidebar/core/sidebar.server.ts
@@ -1,22 +1,32 @@
+import { cachified } from "@epic-web/cachified";
import { href } from "react-router";
+import * as R from "remeda";
+import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
import {
+ COMBINED_STREAMS_KEY,
getLiveTournamentStreams,
type SidebarStream,
} from "~/features/core/streams/streams.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import { resolveFriendActivity } from "~/features/friends/friends-utils.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
+import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
+import { cache, ttl } from "~/utils/cache.server";
import {
+ BLANK_IMAGE_URL,
+ discordAvatarUrl,
navIconUrl,
sendouQMatchPage,
tournamentBracketsPage,
tournamentMatchPage,
+ twitchUrl,
userPage,
} from "~/utils/urls";
+import * as StreamRanking from "./StreamRanking";
export type SidebarEvent = {
id: number;
@@ -39,15 +49,18 @@ export type SidebarFriend = {
tournamentId: number | null;
};
-const MAX_EVENTS_VISIBLE = 8;
+const MAX_EVENTS_VISIBLE = 5;
const MAX_FRIENDS_VISIBLE = 4;
+const MAX_STREAMS_VISIBLE = 5;
const SENDOUQ_QUOTA = 2;
const TOURNAMENT_SUB_QUOTA = 2;
export async function resolveSidebarData(userId: number | null) {
if (!userId) {
+ const tournamentsData =
+ await ShowcaseTournaments.frontPageTournamentsByUserId(null);
return {
- events: [] as SidebarEvent[],
+ events: showcaseEventsToSidebarEvents(tournamentsData.showcase),
matchStatus: null as { matchId: number; url: string } | null,
tournamentMatchStatus: null as {
url: string;
@@ -56,7 +69,7 @@ export async function resolveSidebarData(userId: number | null) {
logoUrl: string | null;
} | null,
friends: [] as SidebarFriend[],
- streams: await combinedStreams(),
+ streams: await combinedStreamsCached(),
};
}
@@ -101,9 +114,14 @@ export async function resolveSidebarData(userId: number | null) {
scrimStatus: s.isAccepted ? ("booked" as const) : ("looking" as const),
}));
- const events = [...tournamentEvents, ...scrimEvents]
- .sort((a, b) => a.startTime - b.startTime)
- .slice(0, MAX_EVENTS_VISIBLE);
+ const personalEvents = [...tournamentEvents, ...scrimEvents].sort(
+ (a, b) => a.startTime - b.startTime,
+ );
+ const events = (
+ personalEvents.length > 0
+ ? personalEvents
+ : showcaseEventsToSidebarEvents(tournamentsData.showcase)
+ ).slice(0, MAX_EVENTS_VISIBLE);
const friends = resolveFriends(friendsWithActivity);
@@ -114,17 +132,91 @@ export async function resolveSidebarData(userId: number | null) {
: null,
tournamentMatchStatus,
friends,
- streams: await combinedStreams(),
+ streams: await combinedStreamsCached(),
};
}
+function combinedStreamsCached(): Promise {
+ return cachified({
+ key: COMBINED_STREAMS_KEY,
+ cache,
+ ttl: ttl(10 * 60 * 1000),
+ async getFreshValue() {
+ return combinedStreams();
+ },
+ });
+}
+
async function combinedStreams(): Promise {
- const [tournamentStreams, sendouQStreams] = await Promise.all([
- getLiveTournamentStreams(),
+ const tournamentStreams = getLiveTournamentStreams();
+ const [sendouQEntries, xRankRows] = await Promise.all([
getSendouQSidebarStreams(),
+ LiveStreamRepository.findXRankStreams(),
]);
- return [...tournamentStreams, ...sendouQStreams];
+ const seenUsernames = new Set(
+ sendouQEntries.flatMap((e) =>
+ e.twitchUsernames.map((t) => t.toLowerCase()),
+ ),
+ );
+
+ const ranked: { stream: SidebarStream; score: number }[] = [];
+
+ for (const stream of tournamentStreams) {
+ ranked.push({
+ stream,
+ score: StreamRanking.tournamentTierToScore(stream.tier),
+ });
+ }
+
+ for (const { sidebarStream, tier } of sendouQEntries) {
+ const score = tier ? StreamRanking.sendouQTierToScore(tier) : 9;
+ ranked.push({ stream: sidebarStream, score });
+ }
+
+ const xRankByUser = new Map();
+ for (const row of xRankRows) {
+ const existing = xRankByUser.get(row.id);
+ if (!existing || (row.peakXp ?? 0) > (existing.peakXp ?? 0)) {
+ xRankByUser.set(row.id, row);
+ }
+ }
+
+ for (const row of xRankByUser.values()) {
+ if (
+ row.twitchUsername &&
+ seenUsernames.has(row.twitchUsername.toLowerCase())
+ ) {
+ continue;
+ }
+
+ const score = StreamRanking.xpToScore(row.peakXp ?? 0);
+ if (score === null) continue;
+
+ ranked.push({
+ stream: {
+ id: row.id,
+ name: row.username,
+ imageUrl: row.discordAvatar
+ ? discordAvatarUrl({
+ discordId: row.discordId,
+ discordAvatar: row.discordAvatar,
+ size: "sm",
+ })
+ : BLANK_IMAGE_URL,
+ url: row.twitchUsername
+ ? twitchUrl(row.twitchUsername)
+ : userPage({ discordId: row.discordId, customUrl: row.customUrl }),
+ subtitle: "",
+ startsAt: Math.floor(Date.now() / 1000),
+ tier: null,
+ peakXp: row.peakXp ?? undefined,
+ },
+ score,
+ });
+ }
+
+ return StreamRanking.rank(ranked, MAX_STREAMS_VISIBLE);
}
function resolveTournamentMatchStatus(userId: number) {
@@ -180,29 +272,27 @@ type FriendWithActivity = Awaited<
>[number];
function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
+ const unique = R.uniqueBy(friendsWithActivity, (f) => f.id);
+ const friendRows = unique.filter((f) => f.friendshipId !== null);
+ const teamMemberRows = unique.filter((f) => f.friendshipId === null);
+
const sendouqFriends: SidebarFriend[] = [];
const tournamentSubFriends: SidebarFriend[] = [];
+ const inactiveFriends: FriendWithActivity[] = [];
- for (const friend of friendsWithActivity) {
+ for (const friend of friendRows) {
const activity = resolveFriendActivity(friend.id, friend.tournamentName);
- if (!activity.subtitle) continue;
+ if (!activity.subtitle) {
+ inactiveFriends.push(friend);
+ continue;
+ }
- const url = userPage({
- discordId: friend.discordId,
- customUrl: friend.customUrl,
- });
-
- const sidebarFriend: SidebarFriend = {
- id: friend.id,
- name: friend.username,
- discordId: friend.discordId,
- discordAvatar: friend.discordAvatar,
- url,
- subtitle: activity.subtitle,
- badge: activity.badge ?? "",
- tournamentId: friend.tournamentId,
- };
+ const sidebarFriend = rowToSidebarFriend(
+ friend,
+ activity.subtitle,
+ activity.badge ?? "",
+ );
if (activity.subtitle === "SendouQ") {
sendouqFriends.push(sidebarFriend);
@@ -226,5 +316,70 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
result.push(...[...extraSendouq, ...extraTournament].slice(0, remaining));
}
+ if (result.length < MAX_FRIENDS_VISIBLE) {
+ const shownIds = new Set(result.map((f) => f.id));
+ const inactiveTeamMembers: FriendWithActivity[] = [];
+
+ for (const tm of teamMemberRows) {
+ if (result.length >= MAX_FRIENDS_VISIBLE) break;
+ if (shownIds.has(tm.id)) continue;
+
+ const activity = resolveFriendActivity(tm.id, tm.tournamentName);
+ if (!activity.subtitle) {
+ inactiveTeamMembers.push(tm);
+ continue;
+ }
+
+ result.push(
+ rowToSidebarFriend(tm, activity.subtitle, activity.badge ?? ""),
+ );
+ shownIds.add(tm.id);
+ }
+
+ for (const friend of inactiveFriends) {
+ if (result.length >= MAX_FRIENDS_VISIBLE) break;
+ if (shownIds.has(friend.id)) continue;
+
+ result.push(rowToSidebarFriend(friend, "", ""));
+ shownIds.add(friend.id);
+ }
+
+ for (const tm of inactiveTeamMembers) {
+ if (result.length >= MAX_FRIENDS_VISIBLE) break;
+
+ result.push(rowToSidebarFriend(tm, "", ""));
+ }
+ }
+
return result;
}
+
+function showcaseEventsToSidebarEvents(
+ events: ShowcaseCalendarEvent[],
+): SidebarEvent[] {
+ return events.map((e) => ({
+ id: e.id,
+ name: e.name,
+ url: e.url,
+ logoUrl: e.logoUrl,
+ startTime: e.startTime,
+ type: "tournament" as const,
+ }));
+}
+
+function rowToSidebarFriend(
+ row: FriendWithActivity,
+ subtitle: string,
+ badge: string,
+): SidebarFriend {
+ return {
+ id: row.id,
+ name: row.username,
+ discordId: row.discordId,
+ discordAvatar: row.discordAvatar,
+ url: userPage({ discordId: row.discordId, customUrl: row.customUrl }),
+ subtitle,
+ badge,
+ tournamentId: row.tournamentId,
+ };
+}
diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts
index 8c899a709..30c1fc6c7 100644
--- a/app/features/tournament-bracket/core/Tournament.server.ts
+++ b/app/features/tournament-bracket/core/Tournament.server.ts
@@ -1,4 +1,4 @@
-import { clearLiveStreamsCache } from "~/features/core/streams/streams.server";
+import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
@@ -102,10 +102,7 @@ export async function tournamentFromDBCached(args: {
}) {
const data = notFoundIfFalsy(await tournamentDataCached(args));
- const tournament = new Tournament({ ...data, simulateBrackets: false });
- syncTournamentToRegistry(tournament);
-
- return tournament;
+ return new Tournament({ ...data, simulateBrackets: false });
}
// caching promise ensures that if many requests are made for the same tournament
@@ -145,11 +142,11 @@ function syncTournamentToRegistry(tournament: Tournament) {
if (isRunning) {
RunningTournaments.add(tournament);
if (!wasInRegistry) {
- clearLiveStreamsCache();
+ clearCombinedStreamsCache();
}
} else {
if (wasInRegistry) {
- clearLiveStreamsCache();
+ clearCombinedStreamsCache();
}
RunningTournaments.remove(tournament.ctx.id);
}
diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx
index aed93a050..8b2f3717a 100644
--- a/app/features/tournament/routes/to.$id.register.tsx
+++ b/app/features/tournament/routes/to.$id.register.tsx
@@ -355,7 +355,9 @@ function RegistrationForms() {
{ownTeam ? (
<>
- {tournament.teamsPrePickMaps ? : null}
+ {tournament.teamsPrePickMaps ? (
+
+ ) : null}
>
) : null}
diff --git a/app/root.tsx b/app/root.tsx
index 9d6e5ed98..1aa098646 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -24,6 +24,7 @@ import {
useMatches,
useNavigate,
useNavigation,
+ useRevalidator,
useSearchParams,
} from "react-router";
import { useDebounce } from "react-use";
@@ -155,6 +156,7 @@ function Document({
usePreloadTranslation();
useLoadingIndicator();
useTriggerToasts();
+ useSidebarRevalidation();
return (
{
+ const TEN_MINUTES = 10 * 60 * 1000;
+
+ const revalidate = () => {
+ if (revalidator.state === "idle") {
+ revalidator.revalidate();
+ }
+ };
+
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === "visible") {
+ revalidate();
+ }
+ };
+
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+ const interval = setInterval(revalidate, TEN_MINUTES);
+
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ clearInterval(interval);
+ };
+ }, [revalidator]);
+}
+
function usePreloadTranslation() {
React.useEffect(() => {
void generalI18next.loadNamespaces(allI18nNamespaces());
diff --git a/app/styles/vars.css b/app/styles/vars.css
index b35b04b16..5280bcad6 100644
--- a/app/styles/vars.css
+++ b/app/styles/vars.css
@@ -8,31 +8,31 @@ The hue and chroma values should not be edited manually, instead
use oklch-gamut.ts to generate valid color palettes!
*/
html {
- --_base-h: 260;
- --_base-c-0: 0.00012;
- --_base-c-1: 0.00588;
- --_base-c-2: 0.00744;
- --_base-c-3: 0.0168;
- --_base-c-4: 0.01548;
- --_base-c-5: 0.01632;
- --_base-c-6: 0.01548;
- --_base-c-7: 0.00804;
+ --_base-h: 268;
+ --_base-c-0: 0;
+ --_base-c-1: 0.02418640252723488;
+ --_base-c-2: 0.047119999999999995;
+ --_base-c-3: 0.1064;
+ --_base-c-4: 0.09804;
+ --_base-c-5: 0.10336000000000001;
+ --_base-c-6: 0.09804;
+ --_base-c-7: 0.05092;
- --_acc-h: 270;
+ --_acc-h: 360;
--_acc-c-0: 0.0912;
- --_acc-c-1: 0.2664;
+ --_acc-c-1: 0.21186558872491915;
--_acc-c-2: 0.0816;
--_acc-c-3: 0.06;
- --_acc-c-4: 0.2616;
- --_acc-c-5: 0.1344;
+ --_acc-c-4: 0.2145290094090161;
+ --_acc-c-5: 0.1288211858120959;
- --_second-h: 90;
- --_second-c-0: 0.05295305624247283;
- --_second-c-1: 0.10680119733498437;
+ --_second-h: 180;
+ --_second-c-0: 0.047012120400352186;
+ --_second-c-1: 0.09481890384235897;
--_second-c-2: 0.0816;
--_second-c-3: 0.06;
- --_second-c-4: 0.10814382461004245;
- --_second-c-5: 0.06493861022758024;
+ --_second-c-4: 0.0960108984048409;
+ --_second-c-5: 0.05765298510196014;
--_radius-box: 3;
--_radius-field: 2;
diff --git a/locales/da/friends.json b/locales/da/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/da/friends.json
+++ b/locales/da/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/da/front.json b/locales/da/front.json
index 092064e7e..823ccad12 100644
--- a/locales/da/front.json
+++ b/locales/da/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/de/friends.json b/locales/de/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/de/friends.json
+++ b/locales/de/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/de/front.json b/locales/de/front.json
index 092064e7e..823ccad12 100644
--- a/locales/de/front.json
+++ b/locales/de/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/en/common.json b/locales/en/common.json
index 26dce6b74..dc52e3698 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -8,8 +8,8 @@
"pages.calendar": "Calendar",
"pages.faq": "FAQ",
"pages.builds": "Builds",
- "pages.analyzer": "Build Analyzer",
- "pages.comp-analyzer": "Comp Analyzer",
+ "pages.analyzer": "Analyzer",
+ "pages.comp-analyzer": "Comps",
"pages.maps": "Map Lists",
"pages.plans": "Planner",
"pages.object-damage-calculator": "DMG Calc",
@@ -22,7 +22,7 @@
"pages.popularBuilds": "Popular Builds",
"pages.abilityStats": "Ability Stats",
"pages.xsearch": "Top Search",
- "pages.leaderboards": "Leaderboard",
+ "pages.leaderboards": "Rankings",
"pages.links": "Links",
"pages.art": "Art",
"pages.sendouq": "SendouQ",
diff --git a/locales/en/friends.json b/locales/en/friends.json
index 7fe6a9e5c..4d7f4aaad 100644
--- a/locales/en/friends.json
+++ b/locales/en/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "View tournament",
"friendsList.joinSendouQ": "Join SendouQ",
"friendsList.deleteFriend": "Delete friend",
- "friendsList.deleteConfirm": "Delete {{name}} as a friend?"
+ "friendsList.deleteConfirm": "Delete {{name}} as a friend?",
+ "view.label": "View filter",
+ "view.friends": "Friends",
+ "view.teamMembers": "Team members",
+ "view.all": "All",
+ "teamMembers.empty": "No team members yet"
}
diff --git a/locales/en/front.json b/locales/en/front.json
index 3c11df4f1..38fc66dee 100644
--- a/locales/en/front.json
+++ b/locales/en/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "View past updates",
"sideNav.myCalendar": "Events",
"sideNav.noEvents": "No upcoming events",
+ "sideNav.noStreams": "No streams currently",
"sideNav.friends": "Friends",
+ "sideNav.friends.notLoggedIn": "Log in to follow your friends' activity",
+ "sideNav.friends.noFriends": "Add friends to see their activity here",
"sideNav.streams": "Streams",
"sideNav.matchStarted": "Match #{{matchId}} started!",
"sideNav.tournamentCheckin": "Check-in now",
diff --git a/locales/es-ES/friends.json b/locales/es-ES/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/es-ES/friends.json
+++ b/locales/es-ES/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/es-ES/front.json b/locales/es-ES/front.json
index 092064e7e..823ccad12 100644
--- a/locales/es-ES/front.json
+++ b/locales/es-ES/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/es-US/friends.json b/locales/es-US/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/es-US/friends.json
+++ b/locales/es-US/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/es-US/front.json b/locales/es-US/front.json
index 092064e7e..823ccad12 100644
--- a/locales/es-US/front.json
+++ b/locales/es-US/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/fr-CA/friends.json b/locales/fr-CA/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/fr-CA/friends.json
+++ b/locales/fr-CA/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/fr-CA/front.json b/locales/fr-CA/front.json
index 092064e7e..823ccad12 100644
--- a/locales/fr-CA/front.json
+++ b/locales/fr-CA/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/fr-EU/friends.json b/locales/fr-EU/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/fr-EU/friends.json
+++ b/locales/fr-EU/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/fr-EU/front.json b/locales/fr-EU/front.json
index f4faedb0a..9b15ff757 100644
--- a/locales/fr-EU/front.json
+++ b/locales/fr-EU/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "Ancienne mise à jour",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/he/friends.json b/locales/he/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/he/friends.json
+++ b/locales/he/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/he/front.json b/locales/he/front.json
index 092064e7e..823ccad12 100644
--- a/locales/he/front.json
+++ b/locales/he/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/it/friends.json b/locales/it/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/it/friends.json
+++ b/locales/it/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/it/front.json b/locales/it/front.json
index c405e8744..f93e2ff6e 100644
--- a/locales/it/front.json
+++ b/locales/it/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "Visualizza aggiornamenti passati",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/ja/friends.json b/locales/ja/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/ja/friends.json
+++ b/locales/ja/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/ja/front.json b/locales/ja/front.json
index 092064e7e..823ccad12 100644
--- a/locales/ja/front.json
+++ b/locales/ja/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/ko/friends.json b/locales/ko/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/ko/friends.json
+++ b/locales/ko/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/ko/front.json b/locales/ko/front.json
index 092064e7e..823ccad12 100644
--- a/locales/ko/front.json
+++ b/locales/ko/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/nl/friends.json b/locales/nl/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/nl/friends.json
+++ b/locales/nl/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/nl/front.json b/locales/nl/front.json
index 092064e7e..823ccad12 100644
--- a/locales/nl/front.json
+++ b/locales/nl/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/pl/friends.json b/locales/pl/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/pl/friends.json
+++ b/locales/pl/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/pl/front.json b/locales/pl/front.json
index 092064e7e..823ccad12 100644
--- a/locales/pl/front.json
+++ b/locales/pl/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/pt-BR/friends.json b/locales/pt-BR/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/pt-BR/friends.json
+++ b/locales/pt-BR/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/pt-BR/front.json b/locales/pt-BR/front.json
index 092064e7e..823ccad12 100644
--- a/locales/pt-BR/front.json
+++ b/locales/pt-BR/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/ru/friends.json b/locales/ru/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/ru/friends.json
+++ b/locales/ru/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/ru/front.json b/locales/ru/front.json
index 4422f41f0..ca2ed46cd 100644
--- a/locales/ru/front.json
+++ b/locales/ru/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "Посмотреть прошлые новости",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/locales/zh/friends.json b/locales/zh/friends.json
index 3b1f7a023..8a17841ab 100644
--- a/locales/zh/friends.json
+++ b/locales/zh/friends.json
@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
- "friendsList.deleteConfirm": ""
+ "friendsList.deleteConfirm": "",
+ "view.label": "",
+ "view.friends": "",
+ "view.teamMembers": "",
+ "view.all": "",
+ "teamMembers.empty": ""
}
diff --git a/locales/zh/front.json b/locales/zh/front.json
index 092064e7e..823ccad12 100644
--- a/locales/zh/front.json
+++ b/locales/zh/front.json
@@ -22,7 +22,10 @@
"updates.viewPast": "",
"sideNav.myCalendar": "",
"sideNav.noEvents": "",
+ "sideNav.noStreams": "",
"sideNav.friends": "",
+ "sideNav.friends.notLoggedIn": "",
+ "sideNav.friends.noFriends": "",
"sideNav.streams": "",
"sideNav.matchStarted": "",
"sideNav.tournamentCheckin": "",
diff --git a/migrations/120-friends-system.js b/migrations/120-friends-system.js
index 5b35da31c..7b771cfed 100644
--- a/migrations/120-friends-system.js
+++ b/migrations/120-friends-system.js
@@ -37,6 +37,10 @@ export function up(db) {
`create index friend_request_receiver_id on "FriendRequest"("receiverId")`,
).run();
+ db.prepare(
+ `create index all_team_member_user_id on "AllTeamMember"("userId")`,
+ ).run();
+
db.prepare(
/* sql */ `
INSERT INTO "Friendship" ("userOneId", "userTwoId")