mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-06 05:07:36 -05:00
SQ streams to sidebar initial (also load sq streams from DB)
This commit is contained in:
parent
e497ff39bc
commit
cf4e5b8de9
|
|
@ -255,6 +255,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.sideNavLinkImageContainer {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sideNavLinkImage {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
|
@ -263,6 +268,17 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sideNavLinkOverlayIcon {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.sideNavLinkContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export function SideNavLink({
|
|||
onClick,
|
||||
isActive,
|
||||
imageUrl,
|
||||
overlayIconUrl,
|
||||
user,
|
||||
subtitle,
|
||||
badge,
|
||||
|
|
@ -89,6 +90,7 @@ export function SideNavLink({
|
|||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
isActive?: boolean;
|
||||
imageUrl?: string;
|
||||
overlayIconUrl?: string;
|
||||
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
|
||||
subtitle?: string;
|
||||
badge?: string;
|
||||
|
|
@ -104,7 +106,16 @@ export function SideNavLink({
|
|||
{user ? (
|
||||
<Avatar user={user} size="xxsm" />
|
||||
) : imageUrl ? (
|
||||
<img src={imageUrl} alt="" className={styles.sideNavLinkImage} />
|
||||
<div className={styles.sideNavLinkImageContainer}>
|
||||
<img src={imageUrl} alt="" className={styles.sideNavLinkImage} />
|
||||
{overlayIconUrl ? (
|
||||
<img
|
||||
src={overlayIconUrl}
|
||||
alt=""
|
||||
className={styles.sideNavLinkOverlayIcon}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.sideNavLinkContent}>
|
||||
<span className={styles.sideNavLinkTitle}>{children}</span>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ import { useTranslation } from "react-i18next";
|
|||
import { Link, useFetcher, useLocation, useMatches } from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import type { RootLoaderData } from "~/root";
|
||||
import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates";
|
||||
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { navIconUrl, SETTINGS_PAGE, userPage } from "~/utils/urls";
|
||||
import { Avatar } from "../Avatar";
|
||||
|
|
@ -168,7 +170,7 @@ export function Layout({
|
|||
data?.sidenavCollapsed ?? false,
|
||||
);
|
||||
|
||||
const { t } = useTranslation(["front"]);
|
||||
const { t, i18n } = useTranslation(["front"]);
|
||||
const { formatRelativeDate } = useTimeFormat();
|
||||
const location = useLocation();
|
||||
const navOffset = useNavOffset();
|
||||
|
|
@ -273,19 +275,33 @@ export function Layout({
|
|||
<SideNavHeader icon={<TwitchIcon />}>
|
||||
{t("front:sideNav.streams")}
|
||||
</SideNavHeader>
|
||||
{streams.map((stream) => (
|
||||
<SideNavLink
|
||||
key={stream.id}
|
||||
to={stream.url}
|
||||
imageUrl={stream.imageUrl}
|
||||
subtitle={stream.subtitle}
|
||||
badge={
|
||||
isMounted && stream.startsAt < Date.now() ? "LIVE" : undefined
|
||||
}
|
||||
>
|
||||
{stream.name}
|
||||
</SideNavLink>
|
||||
))}
|
||||
{streams.map((stream) => {
|
||||
const startsAtDate = databaseTimestampToDate(stream.startsAt);
|
||||
|
||||
return (
|
||||
<SideNavLink
|
||||
key={stream.id}
|
||||
to={stream.url}
|
||||
imageUrl={stream.imageUrl}
|
||||
overlayIconUrl={stream.overlayIconUrl}
|
||||
subtitle={
|
||||
isMounted
|
||||
? formatDistanceToNow(startsAtDate, {
|
||||
addSuffix: true,
|
||||
language: i18n.language as LanguageCode,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
badge={
|
||||
isMounted && startsAtDate.getTime() < Date.now()
|
||||
? "LIVE"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{stream.name}
|
||||
</SideNavLink>
|
||||
);
|
||||
})}
|
||||
</SideNav>
|
||||
<MobileNav sidebarData={data?.sidebar} />
|
||||
<div className={styles.container}>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTo
|
|||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import { Status } from "~/modules/brackets-model";
|
||||
import { cache, ttl } from "~/utils/cache.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { tournamentStreamsPage } from "~/utils/urls";
|
||||
|
||||
const FIVE_MINUTES = 5 * 60 * 1000;
|
||||
|
|
@ -17,6 +18,7 @@ export type SidebarStream = {
|
|||
id: number;
|
||||
name: string;
|
||||
imageUrl: string;
|
||||
overlayIconUrl?: string;
|
||||
url: string;
|
||||
subtitle: string;
|
||||
startsAt: number;
|
||||
|
|
@ -38,7 +40,7 @@ export function getLiveTournamentStreams(): Promise<SidebarStream[]> {
|
|||
imageUrl: tournament.ctx.logoUrl,
|
||||
url: tournamentStreamsPage(tournament.ctx.id),
|
||||
subtitle: deriveCurrentRound(tournament),
|
||||
startsAt: tournament.ctx.startTime.getTime(),
|
||||
startsAt: dateToDatabaseTimestamp(tournament.ctx.startTime),
|
||||
tier: tournament.ctx.tier,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,13 @@ export function activeMatchPlayers() {
|
|||
),
|
||||
)
|
||||
.innerJoin("GroupMember", "GroupMember.groupId", "Group.id")
|
||||
.innerJoin("LiveStream", "LiveStream.userId", "GroupMember.userId")
|
||||
.select(({ eb }) => [
|
||||
"GroupMatch.id as groupMatchId",
|
||||
"GroupMatch.createdAt as groupMatchCreatedAt",
|
||||
"LiveStream.twitch as streamTwitch",
|
||||
"LiveStream.viewerCount as streamViewerCount",
|
||||
"LiveStream.thumbnailUrl as streamThumbnailUrl",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("User")
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import cachified from "@epic-web/cachified";
|
||||
import * as R from "remeda";
|
||||
import type { SidebarStream } from "~/features/core/streams/streams.server";
|
||||
import {
|
||||
cachedFullUserLeaderboard,
|
||||
type UserLeaderboardWithAdditionsItem,
|
||||
} from "~/features/leaderboards/core/leaderboards.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { TIERS } from "~/features/mmr/mmr-constants";
|
||||
import { TIERS, type TierName } from "~/features/mmr/mmr-constants";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import * as QStreamsRepository from "~/features/sendouq-streams/QStreamsRepository.server";
|
||||
import { getStreams } from "~/modules/twitch";
|
||||
import type { MappedStream } from "~/modules/twitch/streams";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { navIconUrl, SENDOUQ_STREAMS_PAGE, tierImageUrl } from "~/utils/urls";
|
||||
import { SENDOUQ_STREAMS_KEY } from "../q-streams-constants";
|
||||
|
||||
export function cachedStreams() {
|
||||
|
|
@ -22,7 +24,6 @@ export function cachedStreams() {
|
|||
async getFreshValue() {
|
||||
return streamedMatches({
|
||||
matchPlayers: await QStreamsRepository.activeMatchPlayers(),
|
||||
streams: await getStreams(),
|
||||
leaderboard: await cachedFullUserLeaderboard(season.nth),
|
||||
}).sort((a, b) => {
|
||||
const aTierIndex = TIERS.findIndex(
|
||||
|
|
@ -66,19 +67,13 @@ export function refreshStreamsCache() {
|
|||
|
||||
function streamedMatches({
|
||||
matchPlayers,
|
||||
streams,
|
||||
leaderboard,
|
||||
}: {
|
||||
matchPlayers: QStreamsRepository.ActiveMatchPlayersItem[];
|
||||
streams: MappedStream[];
|
||||
leaderboard: UserLeaderboardWithAdditionsItem[];
|
||||
}) {
|
||||
return matchPlayers.flatMap((player) => {
|
||||
const stream = streams.find(
|
||||
(stream) => stream.twitchUserName === player.user?.twitch?.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!stream) {
|
||||
if (!player.streamTwitch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +82,11 @@ function streamedMatches({
|
|||
);
|
||||
|
||||
return {
|
||||
stream,
|
||||
stream: {
|
||||
thumbnailUrl: player.streamThumbnailUrl,
|
||||
twitchUserName: player.streamTwitch,
|
||||
viewerCount: player.streamViewerCount,
|
||||
},
|
||||
match: {
|
||||
id: player.groupMatchId,
|
||||
createdAt: player.groupMatchCreatedAt,
|
||||
|
|
@ -101,3 +100,74 @@ function streamedMatches({
|
|||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSendouQSidebarStreams(): Promise<SidebarStream[]> {
|
||||
const streams = await cachedStreams();
|
||||
|
||||
const matchIdToStream = R.groupBy(streams, (s) => s.match.id);
|
||||
|
||||
const sidebarStreams: SidebarStream[] = [];
|
||||
|
||||
for (const [matchIdStr, matchStreams] of Object.entries(matchIdToStream)) {
|
||||
const matchId = Number(matchIdStr);
|
||||
const firstStream = matchStreams[0];
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return sidebarStreams.sort((a, b) => {
|
||||
const aTierIndex = getTierIndexFromSubtitle(a.subtitle);
|
||||
const bTierIndex = getTierIndexFromSubtitle(b.subtitle);
|
||||
return aTierIndex - bTierIndex;
|
||||
});
|
||||
}
|
||||
|
||||
function calculateAverageTierForMatch(
|
||||
matchGroups: (typeof SendouQ.groups)[number][],
|
||||
): { name: TierName; isPlus: boolean } | null {
|
||||
if (matchGroups.length !== 2) return null;
|
||||
|
||||
const allTiers = matchGroups
|
||||
.map((g) => g.tier)
|
||||
.filter((t): t is NonNullable<typeof t> => t !== null);
|
||||
|
||||
if (allTiers.length !== 2) return null;
|
||||
|
||||
const tierIndexSum = allTiers.reduce((sum, tier) => {
|
||||
const baseIndex = TIERS.findIndex((t) => t.name === tier.name);
|
||||
const indexWithPlus = baseIndex * 2 + (tier.isPlus ? 0 : 1);
|
||||
return sum + indexWithPlus;
|
||||
}, 0);
|
||||
|
||||
const averageIndex = tierIndexSum / 2;
|
||||
const baseTierIndex = Math.floor(averageIndex / 2);
|
||||
const isPlus = averageIndex % 2 < 1;
|
||||
|
||||
const tierName = TIERS[baseTierIndex]?.name ?? "IRON";
|
||||
|
||||
return { name: tierName, isPlus };
|
||||
}
|
||||
|
||||
function getTierIndexFromSubtitle(subtitle: string): number {
|
||||
const tierName = subtitle.replace("+", "");
|
||||
const isPlus = subtitle.endsWith("+");
|
||||
const baseIndex = TIERS.findIndex((t) => t.name === tierName);
|
||||
if (baseIndex === -1) return 999;
|
||||
return baseIndex * 2 + (isPlus ? 0 : 1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { href } from "react-router";
|
||||
import { getLiveTournamentStreams } from "~/features/core/streams/streams.server";
|
||||
import {
|
||||
getLiveTournamentStreams,
|
||||
type SidebarStream,
|
||||
} from "~/features/core/streams/streams.server";
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
||||
import { getSendouQSidebarStreams } from "~/features/sendouq-streams/core/streams.server";
|
||||
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
|
||||
import {
|
||||
navIconUrl,
|
||||
|
|
@ -51,7 +55,7 @@ export async function resolveSidebarData(userId: number | null) {
|
|||
logoUrl: string | null;
|
||||
} | null,
|
||||
friends: [] as SidebarFriend[],
|
||||
streams: await getLiveTournamentStreams(),
|
||||
streams: await combinedStreams(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -109,10 +113,19 @@ export async function resolveSidebarData(userId: number | null) {
|
|||
: null,
|
||||
tournamentMatchStatus,
|
||||
friends,
|
||||
streams: await getLiveTournamentStreams(),
|
||||
streams: await combinedStreams(),
|
||||
};
|
||||
}
|
||||
|
||||
async function combinedStreams(): Promise<SidebarStream[]> {
|
||||
const [tournamentStreams, sendouQStreams] = await Promise.all([
|
||||
getLiveTournamentStreams(),
|
||||
getSendouQSidebarStreams(),
|
||||
]);
|
||||
|
||||
return [...tournamentStreams, ...sendouQStreams];
|
||||
}
|
||||
|
||||
function resolveTournamentMatchStatus(userId: number) {
|
||||
const tournament = RunningTournaments.getUserTournament(userId);
|
||||
if (!tournament) return null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user