mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 20:26:08 -05:00
Basically just locked in
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
}) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
55
app/features/sidebar/core/StreamRanking.test.ts
Normal file
55
app/features/sidebar/core/StreamRanking.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
app/features/sidebar/core/StreamRanking.ts
Normal file
47
app/features/sidebar/core/StreamRanking.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
30
app/root.tsx
30
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 (
|
||||
<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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"friendsList.viewTournament": "",
|
||||
"friendsList.joinSendouQ": "",
|
||||
"friendsList.deleteFriend": "",
|
||||
"friendsList.deleteConfirm": ""
|
||||
"friendsList.deleteConfirm": "",
|
||||
"view.label": "",
|
||||
"view.friends": "",
|
||||
"view.teamMembers": "",
|
||||
"view.all": "",
|
||||
"teamMembers.empty": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user