Basically just locked in

This commit is contained in:
Kalle
2026-03-06 19:20:00 +02:00
parent 6cf382ed05
commit 978eaf0d35
61 changed files with 1024 additions and 427 deletions

View File

@@ -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;
}

View File

@@ -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({
<TwitchIcon />
<h3>{t("front:sideNav.streams")}</h3>
</header>
{streams.length === 0 ? (
<div className={styles.sideNavEmpty}>
{t("front:sideNav.noStreams")}
</div>
) : null}
<ul className={styles.streamsList}>
{streams.map((stream) => (
<li key={stream.id} className={styles.streamItem}>
<img
src={stream.imageUrl}
alt=""
className={styles.streamItemImage}
/>
<div className={styles.streamItemContent}>
<span className={styles.streamItemName}>{stream.name}</span>
<div className={styles.streamItemMeta}>
{stream.subtitle ? (
<span className={styles.streamItemSubtitle}>
{stream.subtitle}
</span>
) : null}
{stream.startsAt < Date.now() ? (
<span className={styles.streamItemBadge}>LIVE</span>
) : null}
</div>
</div>
</li>
<ListLink
key={stream.id}
to={stream.url}
imageUrl={stream.imageUrl}
overlayIconUrl={stream.overlayIconUrl}
subtitle={
stream.peakXp ? (
<span className={styles.streamXpSubtitle}>
<Image
path={navIconUrl("xsearch")}
alt=""
className={styles.streamXpIcon}
/>
{stream.peakXp}
</span>
) : (
stream.subtitle
)
}
onClick={onClose}
>
{stream.name}
</ListLink>
))}
</ul>
</section>
@@ -317,23 +326,21 @@ function FriendsPanel({
onClose: () => void;
}) {
const { t } = useTranslation(["front", "common"]);
const user = useUser();
return (
<MobilePanel title={t("front:sideNav.friends")} onClose={onClose}>
{friends.map((friend) => (
<ListLink
key={friend.id}
to={friend.url}
user={{
discordId: friend.discordId,
discordAvatar: friend.discordAvatar,
}}
subtitle={friend.subtitle}
badge={friend.badge}
>
{friend.name}
</ListLink>
))}
{friends.length > 0 ? (
friends.map((friend) => (
<FriendMenu key={friend.id} {...friend} onNavigate={onClose} />
))
) : (
<div className="text-lighter text-sm p-2">
{user
? t("front:sideNav.friends.noFriends")
: t("front:sideNav.friends.notLoggedIn")}
</div>
)}
<Link
to={FRIENDS_PAGE}
className={styles.panelSectionLink}

View File

@@ -98,7 +98,7 @@ export function ListLink({
imageUrl?: string;
overlayIconUrl?: string;
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
subtitle?: string | null;
subtitle?: React.ReactNode;
badge?: string | null;
badgeVariant?: "default" | "warning";
}) {

View File

@@ -1,18 +0,0 @@
export function LogOutIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
);
}

View File

@@ -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 (
<Link
to={userPage(user)}
prefetch="intent"
className={clsx(styles.userItem, className)}
>
<Avatar
user={user}
alt={t("header.loggedInAs", {
userName: `${user.username}`,
})}
className={styles.avatar}
size="sm"
/>
</Link>
);
}
return (
<LogInButtonContainer>
<button type="submit" className={styles.logInButton}>
<LogIn /> {t("header.login.discord")}
</button>
</LogInButtonContainer>
);
}

View File

@@ -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}`,
};

View File

@@ -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;
}

View File

@@ -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({
<SideNavHeader
icon={<Users />}
action={
<Link to={FRIENDS_PAGE} className={styles.viewAllLink}>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
user ? (
<Link to={FRIENDS_PAGE} className={styles.viewAllLink}>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
) : null
}
>
{t("front:sideNav.friends")}
</SideNavHeader>
{friends.map((friend) => (
<FriendMenu key={friend.id} {...friend} />
))}
{friends.length > 0 ? (
friends.map((friend) => <FriendMenu key={friend.id} {...friend} />)
) : (
<div className={styles.sideNavEmpty}>
{user
? t("front:sideNav.friends.noFriends")
: t("front:sideNav.friends.notLoggedIn")}
</div>
)}
<SideNavHeader icon={<TwitchIcon />}>
{t("front:sideNav.streams")}
</SideNavHeader>
{streams.length === 0 ? (
<div className={styles.sideNavEmpty}>
{t("front:sideNav.noStreams")}
</div>
) : 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 ? (
<span className={styles.streamXpSubtitle}>
<img
src={`${navIconUrl("xsearch")}.png`}
alt=""
className={styles.streamXpIcon}
/>
{stream.peakXp}
</span>
) : 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({
}}
>
<MobileLogo />
{sideNavCollapsed ? (
<div className={styles.headerCollapsedBreadcrumbs}>
<SiteTitle />
</div>
) : null}
<SideNavCollapseButton
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
/>
@@ -487,10 +519,17 @@ function SideNavUserPanel() {
}
return (
<LogInButtonContainer>
<SendouButton type="submit" size="small" icon={<LogIn />}>
{t("header.login.discord")}
</SendouButton>
</LogInButtonContainer>
<>
<LogInButtonContainer>
<SendouButton type="submit" size="small" icon={<LogIn />}>
{t("header.login.discord")}
</SendouButton>
</LogInButtonContainer>
<div className={sideNavStyles.sideNavFooterActions}>
<Link to={SETTINGS_PAGE} className={sideNavStyles.sideNavFooterButton}>
<Settings />
</Link>
</div>
</>
);
}

View File

@@ -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<SidebarStream[]> {
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;
}

View File

@@ -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) {

View File

@@ -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({
}
>
<SendouMenuSection headerText={friendSinceText ?? undefined}>
<SendouMenuItem href={url} icon={<User />}>
<SendouMenuItem href={url} icon={<User />} onAction={onNavigate}>
{t("friends:friendsList.viewUserPage")}
</SendouMenuItem>
{activityHref ? (
<SendouMenuItem href={activityHref.url} icon={<Swords />}>
<SendouMenuItem
href={activityHref.url}
icon={<Swords />}
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;

View File

@@ -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: {

View File

@@ -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);
}

View File

@@ -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<FriendsLoaderData>();
@@ -136,19 +141,75 @@ function PendingRequestsSection() {
function FriendsListSection() {
const { t } = useTranslation(["common", "friends"]);
const data = useLoaderData<FriendsLoaderData>();
const [filter, setFilter] = useState<ViewFilter>("friends");
const viewLabels: Record<ViewFilter, string> = {
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 (
<section>
<h2 className="text-lg">{t("friends:friendsList.title")}</h2>
{data.friends.length === 0 ? (
<p className="text-lighter text-sm">{t("friends:friendsList.empty")}</p>
<div className={styles.friendsListHeader}>
<h2 className="text-lg">{t("friends:friendsList.title")}</h2>
<RadioGroup
value={filter}
onChange={(v) => setFilter(v as ViewFilter)}
aria-label={t("friends:view.label")}
orientation="horizontal"
className="stack horizontal xs"
>
{(["friends", "team", "all"] as const).map((value) => (
<Radio key={value} value={value}>
{({ isSelected }) => (
<span
className={clsx(styles.filterRadio, {
[styles.filterRadioSelected]: isSelected,
})}
>
{viewLabels[value]}
</span>
)}
</Radio>
))}
</RadioGroup>
</div>
{shownItems.length === 0 ? (
<p className="text-lighter text-sm">{t(emptyKey)}</p>
) : (
<div className="stack xs">
{data.friends.map((friend) => (
<FriendMenu key={friend.id} name={friend.username} {...friend} />
{shownItems.map((item) => (
<FriendMenu key={item.id} name={item.username} {...item} />
))}
</div>
)}
</section>
);
}
function resolveShownItems(
filter: ViewFilter,
data: Awaited<ReturnType<FriendsLoaderData>>,
) {
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;
});
}

View File

@@ -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;

View File

@@ -96,6 +96,7 @@ export default function LFGPage() {
const activeFilterCount = countActiveFilters(filters);
// xxx: undo changes that introduce the panel here
return (
<Main
className="stack xl"

View File

@@ -1,5 +1,7 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import * as StreamRanking from "../sidebar/core/StreamRanking";
export function replaceAll(
streams: Omit<TablesInsertable["LiveStream"], "id">[],
@@ -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();
}

View File

@@ -417,39 +417,59 @@ export async function findUserScrims(userId: number): Promise<SidebarScrim[]> {
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,
};
});
}

View File

@@ -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<SidebarStream[]> {
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<SidebarStream[]> {
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;
});
}

View File

@@ -53,7 +53,21 @@ export default function SettingsPage() {
return (
<Main halfWidth>
<div className="stack md">
<h2 className="text-lg">{t("common:pages.settings")}</h2>
<div className="stack horizontal justify-between">
<h2 className="text-lg">{t("common:pages.settings")}</h2>
{user ? (
<form method="post" action={LOG_OUT_URL}>
<SendouButton
size="small"
variant="outlined"
icon={<LogOut />}
type="submit"
>
{t("common:header.logout")}
</SendouButton>
</form>
) : null}
</div>
<Divider className={styles.divider} smallText>
{t("common:settings.locales")}
</Divider>
@@ -69,11 +83,6 @@ export default function SettingsPage() {
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
) : null}
<Divider className={styles.divider} smallText>
{t("common:settings.theme")}
</Divider>
<ThemeSelector />
<CustomColorSelector />
{user ? (
<>
<Divider className={styles.divider} smallText>
@@ -111,18 +120,13 @@ export default function SettingsPage() {
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
<form method="post" action={LOG_OUT_URL} className="mt-6">
<SendouButton
size="small"
variant="outlined"
icon={<LogOut />}
type="submit"
>
{t("common:header.logout")}
</SendouButton>
</form>
</>
) : null}
<Divider className={styles.divider} smallText>
{t("common:settings.theme")}
</Divider>
<ThemeSelector />
<CustomColorSelector />
</div>
</Main>
);

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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<SidebarStream[]> {
return cachified({
key: COMBINED_STREAMS_KEY,
cache,
ttl: ttl(10 * 60 * 1000),
async getFreshValue() {
return combinedStreams();
},
});
}
async function combinedStreams(): Promise<SidebarStream[]> {
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<number, (typeof xRankRows)[number]>();
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,
};
}

View File

@@ -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);
}

View File

@@ -355,7 +355,9 @@ function RegistrationForms() {
{ownTeam ? (
<>
<FillRoster ownTeam={ownTeam} ownTeamCheckedIn={ownTeamCheckedIn} />
{tournament.teamsPrePickMaps ? <CounterPickMapPoolPicker /> : null}
{tournament.teamsPrePickMaps ? (
<CounterPickMapPoolPicker key={tournament.ctx.id} />
) : null}
</>
) : null}
</div>

View File

@@ -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 (
<html
@@ -252,6 +254,34 @@ function useLoadingIndicator() {
);
}
function useSidebarRevalidation() {
const revalidator = useRevalidator();
useEffect(() => {
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());

View File

@@ -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;

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -10,5 +10,10 @@
"friendsList.viewTournament": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": ""
"friendsList.deleteConfirm": "",
"view.label": "",
"view.friends": "",
"view.teamMembers": "",
"view.all": "",
"teamMembers.empty": ""
}

View File

@@ -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": "",

View File

@@ -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")