From b94b5fd503a05bb9f0cee0366e002f2ba29cf7fd Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:37:46 +0300 Subject: [PATCH 01/13] Break standings ties with underground bracket results, handle UG source not as first bracket in the order --- .../core/Progression.test.ts | 8 + .../tournament-bracket/core/Progression.ts | 28 +++- .../core/tests/test-utils.ts | 42 +++++ .../tournament/core/Standings.test.ts | 156 +++++++++++++++++- app/features/tournament/core/Standings.ts | 85 +++++++++- 5 files changed, 313 insertions(+), 6 deletions(-) diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index 2f93d1d3a..e10a1e687 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -1392,6 +1392,14 @@ describe("bracketIdxsForStandings", () => { ), ).toEqual([0]); // missing 1 because it's underground when SE is the source }); + + it("does not treat a bracket as intermediate just because an underground bracket sources from it", () => { + expect( + Progression.bracketIdxsForStandings( + progressions.swissToTwoSingleEliminationsWithUnderground, + ), + ).toEqual([1, 2, 0]); // missing 3 because it's underground + }); }); describe("startingBrackets", () => { diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index 2cde3fdc9..e5817e662 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -931,11 +931,18 @@ export function bracketIdxsForStandings(progression: ParsedBracket[]) { const bracketsToConsider = bracketsReachableFrom(0, progression); const withoutIntermediateBrackets = bracketsToConsider.filter( - (bracket, bracketIdx) => { + (bracketIdx) => { if (bracketIdx === 0) return true; + // underground brackets don't make their source bracket an intermediate one + const undergrounds = new Set( + undergroundBracketIdxs(bracketIdx, progression), + ); + return progression.every( - (b) => !b.sources?.some((s) => s.bracketIdx === bracket), + (b, idx) => + undergrounds.has(idx) || + !b.sources?.some((s) => s.bracketIdx === bracketIdx), ); }, ); @@ -1033,6 +1040,23 @@ export function destinationsFromBracketIdx( return destinations; } +/** + * Returns the indexes of the underground brackets sourced from the given bracket. + * An underground bracket is one that takes teams eliminated from its source bracket (negative placements). + */ +export function undergroundBracketIdxs( + bracketIdx: number, + progression: ParsedBracket[], +): number[] { + return destinationsFromBracketIdx(bracketIdx, progression).filter((idx) => + progression[idx].sources?.some( + (source) => + source.bracketIdx === bracketIdx && + source.placements.some((placement) => placement < 0), + ), + ); +} + export function destinationByPlacement({ sourceBracketIdx, placement, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index a28ddbb7c..cfe53ed5c 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -336,4 +336,46 @@ export const progressions = { ], }, ], + swissToTwoSingleEliminationsWithUnderground: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "swiss", + settings: { + groupCount: 1, + }, + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Alpha", + sources: [ + { + bracketIdx: 0, + placements: [1, 2, 3, 4, 5, 6, 7, 8], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Beta", + sources: [ + { + bracketIdx: 0, + placements: [9, 10, 11, 12, 13, 14, 15, 16], + }, + ], + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Alpha UG", + sources: [ + { + bracketIdx: 1, + placements: [-1], + }, + ], + }, + ], } satisfies Record; diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts index 7c19766e6..a13eee60f 100644 --- a/app/features/tournament/core/Standings.test.ts +++ b/app/features/tournament/core/Standings.test.ts @@ -80,6 +80,77 @@ describe("tournamentStandings", () => { expect(a.standings.map((s) => s.placement)).toEqual([1, 2]); expect(b.standings.map((s) => s.placement)).toEqual([1, 2]); }); + + it("breaks ties of a bracket with the results of its underground bracket", () => { + const tournament = singleEliminationWithUndergroundTournament(); + + const result = tournamentStandings(tournament); + + invariant(result.type === "single"); + // teams 5-8 all lost the quarterfinals so they are tied in the main bracket, + // the underground bracket (won by 8, then 7, 6, 5) decides their order + expect(result.standings.map((s) => s.team.id)).toEqual([ + 1, 2, 3, 4, 8, 7, 6, 5, + ]); + expect(result.standings.map((s) => s.placement)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, + ]); + }); + + it("keeps teams that skipped the underground bracket tied below those who played it", () => { + const tournament = singleEliminationWithUndergroundTournament({ + undergroundSeeding: [7, 8], + }); + + const result = tournamentStandings(tournament); + + invariant(result.type === "single"); + expect(result.standings.map((s) => s.team.id)).toEqual([ + 1, 2, 3, 4, 8, 7, 5, 6, + ]); + expect(result.standings.map((s) => s.placement)).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 7, + ]); + }); + + it("does not break ties with an underground bracket that is still in progress", () => { + // only the semi-finals of the underground bracket have been played so the two teams + // still alive there have no placement yet + const tournament = singleEliminationWithUndergroundTournament({ + undergroundConsolationFinal: false, + undergroundMatchesPlayed: 2, + }); + + const result = tournamentStandings(tournament); + + invariant(result.type === "single"); + // teams 5-8 keep the order & tied placement they have in the main bracket, + // the teams eliminated from the underground bracket are not sorted above those still in it + expect(result.standings.map((s) => s.team.id)).toEqual([ + 1, 2, 3, 4, 8, 5, 7, 6, + ]); + expect(result.standings.map((s) => s.placement)).toEqual([ + 1, 2, 3, 4, 5, 5, 5, 5, + ]); + }); + + it("does not break ties with an underground bracket that was never started", () => { + // an underground bracket set in the progression can be skipped altogether + const tournament = singleEliminationWithUndergroundTournament({ + undergroundStarted: false, + }); + expect(tournament.bracketByIdx(1)?.preview).toBe(true); + + const result = tournamentStandings(tournament); + + invariant(result.type === "single"); + expect(result.standings.map((s) => s.team.id)).toEqual([ + 1, 2, 3, 4, 8, 5, 7, 6, + ]); + expect(result.standings.map((s) => s.placement)).toEqual([ + 1, 2, 3, 4, 5, 5, 5, 5, + ]); + }); }); describe("reNumberPlacements", () => { @@ -216,6 +287,68 @@ function singleEliminationTournament() { }); } +function singleEliminationWithUndergroundTournament({ + undergroundSeeding = [5, 6, 7, 8], + undergroundConsolationFinal = undergroundSeeding.length > 2, + undergroundMatchesPlayed, + undergroundStarted = true, +}: { + undergroundSeeding?: number[]; + undergroundConsolationFinal?: boolean; + undergroundMatchesPlayed?: number; + undergroundStarted?: boolean; +} = {}) { + const mainBracket = playOut( + createResolved({ + type: "single_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: { consolationFinal: true }, + }), + (one, two) => one < two, + ); + + const data = undergroundStarted + ? mergeStages( + mainBracket, + playOut( + createResolved({ + type: "single_elimination", + seeding: undergroundSeeding, + settings: { consolationFinal: undergroundConsolationFinal }, + }), + (one, two) => one > two, + undergroundMatchesPlayed, + ), + ) + : mainBracket; + + return testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "single_elimination", + name: "Main Bracket", + requiresCheckIn: false, + settings: { thirdPlaceMatch: true }, + }, + { + type: "single_elimination", + name: "Underground", + requiresCheckIn: false, + settings: { thirdPlaceMatch: true }, + sources: [{ bracketIdx: 0, placements: [-1] }], + }, + ], + }, + teams: [1, 2, 3, 4, 5, 6, 7, 8].map((id) => + tournamentCtxTeam(id, { startingBracketIdx: 0, seed: id }), + ), + }, + data, + }); +} + function abDivisionsTournament() { let data = createResolved({ type: "round_robin", @@ -273,9 +406,22 @@ function abDivisionsTournament() { /** Plays every match of the bracket data, the lower team id always winning. */ function playOutLowerIdWins(data: BracketData) { - let played = data; + return playOut(data, (one, two) => one < two); +} - while (true) { +/** + * Plays every match of the bracket data, `opponent1Wins` deciding each match by team id. + * `maxMatches` can be given to leave the bracket in progress. + */ +function playOut( + data: BracketData, + opponent1Wins: (opponent1Id: number, opponent2Id: number) => boolean, + maxMatches = Number.POSITIVE_INFINITY, +) { + let played = data; + let playedCount = 0; + + while (playedCount < maxMatches) { const pending = played.match.find( (match) => typeof match.opponent1?.id === "number" && @@ -284,12 +430,16 @@ function playOutLowerIdWins(data: BracketData) { ); if (!pending) break; - const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!; + const winnerIsOpp1 = opponent1Wins( + pending.opponent1!.id as number, + pending.opponent2!.id as number, + ); played = Engine.reportResult(played, { matchId: pending.id, scores: [winnerIsOpp1 ? 2 : 0, winnerIsOpp1 ? 0 : 2], winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2", }).data; + playedCount++; } return played; diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts index 44155254a..9207ae067 100644 --- a/app/features/tournament/core/Standings.ts +++ b/app/features/tournament/core/Standings.ts @@ -257,7 +257,11 @@ function tournamentStandingsForBracket( const standings = standingsToMergeable({ alreadyIncludedTeamIds, - standings: bracket.standings, + standings: tiebrokenByUndergroundBrackets({ + tournament, + bracketIdx: idx, + standings: bracket.standings, + }), teamsAboveFromAnotherBracketsCount: alreadyIncludedTeamIds.size, }); result.push(...standings); @@ -273,6 +277,85 @@ function tournamentStandingsForBracket( return result; } +/** + * Underground brackets are left out of the standings but the teams playing them are tied in their source + * bracket (e.g. everyone who lost the quarterfinals shares the same placement), so their underground run + * decides the order within each such tie. Teams that skipped the underground bracket stay tied last. + * + * An underground bracket that is still in progress is ignored, as the teams still alive in it have no + * placement yet and would sort below the teams it already eliminated. + */ +function tiebrokenByUndergroundBrackets({ + tournament, + bracketIdx, + standings, +}: { + tournament: Tournament; + bracketIdx: number; + standings: Standing[]; +}): Standing[] { + const undergroundPlacements = new Map(); + + for (const undergroundIdx of Progression.undergroundBracketIdxs( + bracketIdx, + tournament.ctx.settings.bracketProgression, + )) { + const underground = tournament.bracketByIdx(undergroundIdx); + if (!underground?.everyMatchOver) continue; + + for (const standing of underground.standings) { + if (undergroundPlacements.has(standing.team.id)) continue; + + undergroundPlacements.set(standing.team.id, standing.placement); + } + } + + if (undergroundPlacements.size === 0) return standings; + + const result: Standing[] = []; + + for (const tied of groupedByPlacement(standings)) { + const sorted = R.sortBy( + tied, + (standing) => + undergroundPlacements.get(standing.team.id) ?? Number.POSITIVE_INFINITY, + ); + + let placement = tied[0].placement; + let previousUndergroundPlacement: number | null = null; + + for (const [index, standing] of sorted.entries()) { + const undergroundPlacement = + undergroundPlacements.get(standing.team.id) ?? null; + + if (index > 0 && undergroundPlacement !== previousUndergroundPlacement) { + placement = tied[0].placement + index; + } + previousUndergroundPlacement = undergroundPlacement; + + result.push({ ...standing, placement }); + } + } + + return result; +} + +function groupedByPlacement(standings: Standing[]): Standing[][] { + const result: Standing[][] = []; + + for (const standing of standings) { + const previous = result.at(-1); + + if (previous && previous[0].placement === standing.placement) { + previous.push(standing); + } else { + result.push([standing]); + } + } + + return result; +} + function standingsToMergeable< T extends { team: { id: number }; placement: number }, >({ From bb374145c12ab8a4b070137ff3aa36cb877baf68 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:43:53 +0300 Subject: [PATCH 02/13] Show set end time above match banner next to start time --- .../match-page/MatchBannerStartedAt.tsx | 40 ++++++++++++++----- .../components/SendouQMatchBanner.tsx | 9 ++++- .../components/TournamentMatchBanner.tsx | 8 +++- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/app/components/match-page/MatchBannerStartedAt.tsx b/app/components/match-page/MatchBannerStartedAt.tsx index 53601cdb1..20347355a 100644 --- a/app/components/match-page/MatchBannerStartedAt.tsx +++ b/app/components/match-page/MatchBannerStartedAt.tsx @@ -1,21 +1,43 @@ import { LocaleTime } from "~/components/LocaleTime"; +import { LocaleTimeRange } from "~/components/LocaleTimeRange"; + +const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = { + month: "numeric", + year: "2-digit", + day: "numeric", + hour: "numeric", + minute: "numeric", +}; + +const CLASS_NAME = "text-lighter font-semi-bold"; interface MatchBannerStartedAtProps { time: Date; + /** When given, the time the match ended, shown as a range together with the start time */ + endTime?: Date | null; } -export function MatchBannerStartedAt({ time }: MatchBannerStartedAtProps) { +export function MatchBannerStartedAt({ + time, + endTime, +}: MatchBannerStartedAtProps) { + if (endTime) { + return ( + + ); + } + return ( ); diff --git a/app/features/sendouq-match/components/SendouQMatchBanner.tsx b/app/features/sendouq-match/components/SendouQMatchBanner.tsx index a488fee9a..de57439e8 100644 --- a/app/features/sendouq-match/components/SendouQMatchBanner.tsx +++ b/app/features/sendouq-match/components/SendouQMatchBanner.tsx @@ -154,7 +154,14 @@ function SendouQMatchBannerTopRow({ }} > {data.match.isLocked || awaitingConfirmation ? ( - + ) : ( {data.matchIsOver ? ( - + ) : ( )} From adce1d2099bb142fd5d2c031d884124c3bd4344e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:33:05 +0300 Subject: [PATCH 03/13] Show friends list LIVE badge only when there is a stream to watch and link to streams --- app/components/SideNav.module.css | 1 + app/components/match-page/MatchTimeline.tsx | 2 +- .../friends/components/FriendMenu.tsx | 36 +++++- app/features/friends/friends-constants.ts | 37 +++--- app/features/friends/friends-utils.server.ts | 107 +++++++++++++++++- .../friends/loaders/friends.server.ts | 26 +++-- app/features/sidebar/core/sidebar.server.ts | 36 +++--- .../tournament-bracket/core/Tournament.ts | 1 + locales/da/friends.json | 3 + locales/da/q.json | 2 +- locales/de/friends.json | 3 + locales/de/q.json | 2 +- locales/en/friends.json | 3 + locales/en/q.json | 2 +- locales/es-ES/friends.json | 3 + locales/es-ES/q.json | 2 +- locales/es-US/friends.json | 3 + locales/es-US/q.json | 2 +- locales/fr-CA/friends.json | 3 + locales/fr-CA/q.json | 2 +- locales/fr-EU/friends.json | 3 + locales/fr-EU/q.json | 2 +- locales/he/friends.json | 3 + locales/he/q.json | 2 +- locales/it/friends.json | 3 + locales/it/q.json | 2 +- locales/ja/friends.json | 3 + locales/ja/q.json | 2 +- locales/ko/friends.json | 3 + locales/ko/q.json | 2 +- locales/nl/friends.json | 3 + locales/nl/q.json | 2 +- locales/pl/friends.json | 3 + locales/pl/q.json | 2 +- locales/pt-BR/friends.json | 3 + locales/pt-BR/q.json | 2 +- locales/ru/friends.json | 3 + locales/ru/q.json | 2 +- locales/zh/friends.json | 3 + locales/zh/q.json | 2 +- 40 files changed, 261 insertions(+), 65 deletions(-) diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css index 3e2d4a4e9..b36a14270 100644 --- a/app/components/SideNav.module.css +++ b/app/components/SideNav.module.css @@ -268,6 +268,7 @@ .listLinkSubtitleRow { display: flex; align-items: center; + gap: var(--s-1-5); width: 100%; color: var(--color-text-high); } diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx index 5c022a127..bfb78c12c 100644 --- a/app/components/match-page/MatchTimeline.tsx +++ b/app/components/match-page/MatchTimeline.tsx @@ -185,7 +185,7 @@ function TimelineHeader({ ) : null} {isOngoing ? ( - {t("q:match.timeline.live")} + {t("q:match.timeline.ongoing")} ) : null} diff --git a/app/features/friends/components/FriendMenu.tsx b/app/features/friends/components/FriendMenu.tsx index 5b394de32..635fa12d7 100644 --- a/app/features/friends/components/FriendMenu.tsx +++ b/app/features/friends/components/FriendMenu.tsx @@ -9,10 +9,12 @@ import { SendouMenuItem, SendouMenuSection, } from "~/components/elements/Menu"; +import { TwitchIcon } from "~/components/icons/Twitch"; import { ListButton } from "~/components/SideNav"; import { + type FriendActivityBadge, type FriendActivityType, - isLiveFriendActivity, + friendActivityBadge, } from "~/features/friends/friends-constants"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { @@ -23,6 +25,11 @@ import { tournamentSubsPage, } from "~/utils/urls"; +const ACTIVITY_BADGE_TRANSLATION_KEY = { + MATCH: "friends:friendsList.inMatch", + NEXT: "friends:friendsList.nextMatch", +} as const satisfies Record; + export function FriendMenu({ discordId, discordAvatar, @@ -34,6 +41,7 @@ export function FriendMenu({ activityType, matchId, tournamentId, + streamUrl, friendshipId, friendshipCreatedAt, onNavigate, @@ -48,6 +56,7 @@ export function FriendMenu({ activityType: FriendActivityType | null; matchId: number | null; tournamentId: number | null; + streamUrl: string | null; friendshipId?: number; friendshipCreatedAt?: number | null; onNavigate?: () => void; @@ -67,7 +76,7 @@ export function FriendMenu({ }) : null; - const isLive = isLiveFriendActivity(activityType); + const activityBadge = friendActivityBadge(activityType); const activity = resolveActivity({ activityType, matchId, tournamentId }); return ( @@ -77,8 +86,14 @@ export function FriendMenu({ {name} @@ -88,6 +103,17 @@ export function FriendMenu({ } onAction={onNavigate}> {t("friends:friendsList.viewUserPage")} + {streamUrl ? ( + } + onAction={onNavigate} + > + {t("friends:friendsList.watchStream")} + + ) : null} {activity?.type === "join-sendouq" ? ( } @@ -189,7 +215,7 @@ function resolveActivity(friend: { }), } as const) : null; - case "TOURNAMENT_PLAYING": + case "TOURNAMENT_WAITING": return friend.tournamentId ? ({ type: "view-tournament", diff --git a/app/features/friends/friends-constants.ts b/app/features/friends/friends-constants.ts index 1fe64d496..7500215cf 100644 --- a/app/features/friends/friends-constants.ts +++ b/app/features/friends/friends-constants.ts @@ -8,30 +8,33 @@ export const SENDOUQ_ACTIVITY_LABEL = "SendouQ"; export type FriendActivityType = | "SENDOUQ_MATCH" | "TOURNAMENT_MATCH" - | "TOURNAMENT_PLAYING" + | "TOURNAMENT_WAITING" | "SENDOUQ" | "TOURNAMENT_SUB"; -/** - * Whether the activity represents a friend currently playing (in a live match - * or otherwise busy in a running tournament) as opposed to looking for members. - */ -export function isLiveFriendActivity(type: FriendActivityType | null) { - return ( - type === "SENDOUQ_MATCH" || - type === "TOURNAMENT_MATCH" || - type === "TOURNAMENT_PLAYING" - ); +export type FriendActivityBadge = "MATCH" | "NEXT"; + +const ACTIVITY_BADGE: Record = { + SENDOUQ_MATCH: "MATCH", + TOURNAMENT_MATCH: "MATCH", + TOURNAMENT_WAITING: "NEXT", + SENDOUQ: null, + TOURNAMENT_SUB: null, +}; + +export function friendActivityBadge(type: FriendActivityType | null) { + if (!type) return null; + + return ACTIVITY_BADGE[type]; +} + +export function isInProgressFriendActivity(type: FriendActivityType | null) { + return friendActivityBadge(type) !== null; } -/** - * Sort value used to order friends by how interesting their activity is. - * Looking for members ranks highest (others can act on it), then live activity, - * then no activity. - */ export function friendActivitySortValue(type: FriendActivityType | null) { if (type === "SENDOUQ") return 4; if (type === "TOURNAMENT_SUB") return 3; - if (isLiveFriendActivity(type)) return 2; + if (isInProgressFriendActivity(type)) return 2; return 0; } diff --git a/app/features/friends/friends-utils.server.ts b/app/features/friends/friends-utils.server.ts index 25bacaf8e..0e4c4febd 100644 --- a/app/features/friends/friends-utils.server.ts +++ b/app/features/friends/friends-utils.server.ts @@ -1,8 +1,13 @@ import { groupExpiryStatus } from "~/features/sendouq/core/groups"; import { SendouQ } from "~/features/sendouq/core/SendouQ.server"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import { cachedStreams } from "~/features/sendouq-streams/core/streams.server"; import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server"; -import type { TournamentTeamMemberProgressStatus } from "~/features/tournament-bracket/core/Tournament"; +import type { + Tournament, + TournamentTeamMemberProgressStatus, +} from "~/features/tournament-bracket/core/Tournament"; +import { twitchUrl } from "~/utils/urls"; import { type FriendActivityType, SENDOUQ_ACTIVITY_LABEL, @@ -14,6 +19,8 @@ export interface FriendActivity { badge: string | null; matchId: number | null; tournamentId: number | null; + /** Set when the friend's current match can be watched, making the activity show up as "LIVE". */ + streamUrl: string | null; } const TOURNAMENT_STATUS_IS_IN_PROGRESS: Record< @@ -25,15 +32,32 @@ const TOURNAMENT_STATUS_IS_IN_PROGRESS: Record< WAITING_FOR_CAST: true, WAITING_FOR_ROUND: true, WAITING_FOR_GROUPS: true, - // to counter 2 day tournaments showing LIVE in between + // to counter 2 day tournaments showing as in progress in between WAITING_FOR_BRACKET: false, CHECKIN: false, THANKS_FOR_PLAYING: false, }; +/** + * Twitch account streaming each ongoing SendouQ match, keyed by match id. Resolved + * once per request as activity is resolved separately for every friend. + */ +export async function resolveSendouQMatchStreams() { + const streams = await cachedStreams(); + + const result = new Map(); + for (const { match, stream } of streams) { + if (!stream.twitchUserName || result.has(match.id)) continue; + + result.set(match.id, stream.twitchUserName); + } + + return result; +} + /** * Resolves what a friend is currently doing for display in the friends list, - * prioritizing in-progress activity (a live SendouQ or tournament match) over + * prioritizing in-progress activity (an ongoing SendouQ or tournament match) over * looking-for-members activity. */ export function resolveFriendActivity({ @@ -42,22 +66,27 @@ export function resolveFriendActivity({ tournamentName, teamMemberCount, tournamentMinTeamSize, + sendouQMatchStreams, }: { friendId: number; tournamentId: number | null; tournamentName: string | null; teamMemberCount: number | null; tournamentMinTeamSize: number | null; + sendouQMatchStreams: ReadonlyMap; }): FriendActivity { const ownGroup = SendouQ.findOwnGroup(friendId); if (ownGroup?.matchId) { + const twitchAccount = sendouQMatchStreams.get(ownGroup.matchId); + return { type: "SENDOUQ_MATCH", subtitle: SENDOUQ_ACTIVITY_LABEL, badge: null, matchId: ownGroup.matchId, tournamentId: null, + streamUrl: twitchAccount ? twitchUrl(twitchAccount) : null, }; } @@ -75,6 +104,7 @@ export function resolveFriendActivity({ badge: `${ownGroup.members.length}/${FULL_GROUP_SIZE}`, matchId: null, tournamentId: null, + streamUrl: null, }; } @@ -85,6 +115,7 @@ export function resolveFriendActivity({ badge: `${teamMemberCount ?? 1}/${tournamentMinTeamSize ?? FULL_GROUP_SIZE}`, matchId: null, tournamentId, + streamUrl: null, }; } @@ -94,6 +125,7 @@ export function resolveFriendActivity({ badge: null, matchId: null, tournamentId: null, + streamUrl: null, }; } @@ -102,14 +134,79 @@ function resolveTournamentActivity(friendId: number): FriendActivity | null { const status = tournament.teamMemberOfProgressStatus({ id: friendId }); if (!status || !TOURNAMENT_STATUS_IS_IN_PROGRESS[status.type]) continue; + const isInMatch = status.type === "MATCH"; + return { - type: status.type === "MATCH" ? "TOURNAMENT_MATCH" : "TOURNAMENT_PLAYING", + type: isInMatch ? "TOURNAMENT_MATCH" : "TOURNAMENT_WAITING", subtitle: tournament.ctx.name, badge: null, - matchId: status.type === "MATCH" ? status.matchId : null, + matchId: isInMatch ? status.matchId : null, tournamentId: tournament.ctx.id, + streamUrl: isInMatch + ? tournamentStreamUrl({ + tournament, + friendId, + matchId: status.matchId, + opponentId: status.opponentId, + }) + : null, }; } return null; } + +/** + * Where the friend's ongoing tournament match can be watched, preferring the view + * that shows the friend best: their own stream, then a teammate's stream, then the + * official cast of the match and finally an opponent's. + */ +function tournamentStreamUrl({ + tournament, + friendId, + matchId, + opponentId, +}: { + tournament: Tournament; + friendId: number; + matchId: number; + opponentId: number; +}) { + const streamingParticipantIds = new Set(tournament.streamingParticipantIds); + const ownTeamMembers = + tournament.teamMemberOfByUser({ id: friendId })?.members ?? []; + + const friendAccount = streamingTwitchAccount( + ownTeamMembers.filter((member) => member.userId === friendId), + streamingParticipantIds, + ); + if (friendAccount) return twitchUrl(friendAccount); + + const teammateAccount = streamingTwitchAccount( + ownTeamMembers.filter((member) => member.userId !== friendId), + streamingParticipantIds, + ); + if (teammateAccount) return twitchUrl(teammateAccount); + + const castAccount = tournament.ctx.castedMatchesInfo?.castedMatches.find( + (castedMatch) => castedMatch.matchId === matchId, + )?.twitchAccount; + if (castAccount) return twitchUrl(castAccount); + + const opponentAccount = streamingTwitchAccount( + tournament.teamById(opponentId)?.members ?? [], + streamingParticipantIds, + ); + + return opponentAccount ? twitchUrl(opponentAccount) : null; +} + +function streamingTwitchAccount( + players: Array<{ userId: number; streamTwitch: string | null }>, + streamingParticipantIds: ReadonlySet, +) { + return players.find( + (player) => + streamingParticipantIds.has(player.userId) && player.streamTwitch, + )?.streamTwitch; +} diff --git a/app/features/friends/loaders/friends.server.ts b/app/features/friends/loaders/friends.server.ts index 17be3959a..0d0ffbe5a 100644 --- a/app/features/friends/loaders/friends.server.ts +++ b/app/features/friends/loaders/friends.server.ts @@ -3,19 +3,27 @@ import { requireUser } from "~/features/auth/core/user.server"; import { userPage } from "~/utils/urls"; import * as FriendRepository from "../FriendRepository.server"; import { friendActivitySortValue } from "../friends-constants"; -import { resolveFriendActivity } from "../friends-utils.server"; +import { + resolveFriendActivity, + resolveSendouQMatchStreams, +} from "../friends-utils.server"; export type FriendsLoaderData = typeof loader; export const loader = async () => { const user = requireUser(); - const [friendsWithActivity, pendingRequests, incomingRequests] = - await Promise.all([ - FriendRepository.findByUserIdWithActivity(user.id), - FriendRepository.findPendingSentRequests(user.id), - FriendRepository.findPendingReceivedRequests(user.id), - ]); + const [ + friendsWithActivity, + pendingRequests, + incomingRequests, + streamedSendouQMatches, + ] = await Promise.all([ + FriendRepository.findByUserIdWithActivity(user.id), + FriendRepository.findPendingSentRequests(user.id), + FriendRepository.findPendingReceivedRequests(user.id), + resolveSendouQMatchStreams(), + ]); const unique = R.uniqueBy(friendsWithActivity, (f) => f.id); @@ -29,6 +37,7 @@ export const loader = async () => { tournamentName: friend.tournamentName, teamMemberCount: friend.teamMemberCount, tournamentMinTeamSize: friend.tournamentMinTeamSize, + sendouQMatchStreams: streamedSendouQMatches, }); return { @@ -47,6 +56,7 @@ export const loader = async () => { activityType: activity.type, matchId: activity.matchId, tournamentId: activity.tournamentId ?? friend.tournamentId, + streamUrl: activity.streamUrl, friendshipCreatedAt: friend.friendshipCreatedAt, }; }), @@ -64,6 +74,7 @@ export const loader = async () => { tournamentName: tm.tournamentName, teamMemberCount: tm.teamMemberCount, tournamentMinTeamSize: tm.tournamentMinTeamSize, + sendouQMatchStreams: streamedSendouQMatches, }); return { @@ -81,6 +92,7 @@ export const loader = async () => { activityType: activity.type, matchId: activity.matchId, tournamentId: activity.tournamentId ?? tm.tournamentId, + streamUrl: activity.streamUrl, }; }), [(tm) => friendActivitySortValue(tm.activityType), "desc"], diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts index 4aa690c95..bdd82b1f9 100644 --- a/app/features/sidebar/core/sidebar.server.ts +++ b/app/features/sidebar/core/sidebar.server.ts @@ -14,11 +14,12 @@ import { import * as FriendRepository from "~/features/friends/FriendRepository.server"; import { type FriendActivityType, - isLiveFriendActivity, + isInProgressFriendActivity, } from "~/features/friends/friends-constants"; import { type FriendActivity, resolveFriendActivity, + resolveSendouQMatchStreams, } 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"; @@ -60,6 +61,7 @@ export type SidebarFriend = { activityType: FriendActivityType | null; matchId: number | null; tournamentId: number | null; + streamUrl: string | null; }; const MAX_EVENTS_VISIBLE = 5; @@ -86,12 +88,14 @@ export async function resolveSidebarData(userId: number | null) { friendsWithActivity, savedTournaments, incomingFriendRequestIds, + streamedSendouQMatches, ] = await Promise.all([ ShowcaseTournaments.categorizedTournamentsByUserId(userId), ScrimPostRepository.findUserScrims(userId), FriendRepository.findByUserIdWithActivity(userId), SavedCalendarEventRepository.findAllUpcomingByUserId(userId), FriendRepository.findPendingReceivedRequestIds(userId), + resolveSendouQMatchStreams(), ]); const seenTournamentIds = new Set(); @@ -119,7 +123,7 @@ export async function resolveSidebarData(userId: number | null) { .sort((a, b) => a.startsAt - b.startsAt) .slice(0, MAX_EVENTS_VISIBLE); - const friends = resolveFriends(friendsWithActivity); + const friends = resolveFriends(friendsWithActivity, streamedSendouQMatches); const savedTournamentIds = savedTournaments.map((t) => t.id); @@ -277,7 +281,20 @@ type FriendWithActivity = Awaited< ReturnType >[number]; -function resolveFriends(friendsWithActivity: FriendWithActivity[]) { +function resolveFriends( + friendsWithActivity: FriendWithActivity[], + streamedSendouQMatches: ReadonlyMap, +) { + const activityForRow = (row: FriendWithActivity) => + resolveFriendActivity({ + friendId: row.id, + tournamentId: row.tournamentId, + tournamentName: row.tournamentName, + teamMemberCount: row.teamMemberCount, + tournamentMinTeamSize: row.tournamentMinTeamSize, + sendouQMatchStreams: streamedSendouQMatches, + }); + const unique = R.uniqueBy(friendsWithActivity, (f) => f.id); const friendRows = unique.filter((f) => f.friendshipId !== null); const teamMemberRows = unique.filter((f) => f.friendshipId === null); @@ -297,7 +314,7 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) { const sidebarFriend = rowToSidebarFriend(friend, activity); - if (isLiveFriendActivity(activity.type)) { + if (isInProgressFriendActivity(activity.type)) { activeFriends.push(sidebarFriend); } else if (activity.type === "SENDOUQ") { sendouqFriends.push(sidebarFriend); @@ -360,16 +377,6 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) { return result.slice(0, MAX_FRIENDS_VISIBLE); } -function activityForRow(row: FriendWithActivity): FriendActivity { - return resolveFriendActivity({ - friendId: row.id, - tournamentId: row.tournamentId, - tournamentName: row.tournamentName, - teamMemberCount: row.teamMemberCount, - tournamentMinTeamSize: row.tournamentMinTeamSize, - }); -} - function rowToSidebarFriend( row: FriendWithActivity, activity: FriendActivity | null, @@ -386,6 +393,7 @@ function rowToSidebarFriend( activityType: activity?.type ?? null, matchId: activity?.matchId ?? null, tournamentId: activity?.tournamentId ?? row.tournamentId, + streamUrl: activity?.streamUrl ?? null, }; } diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 5c67c8678..8ad8fed8e 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1049,6 +1049,7 @@ export class Tournament { type: "MATCH", matchId: match.id, opponent: otherTeam.name, + opponentId: otherTeam.id, } as const; } diff --git a/locales/da/friends.json b/locales/da/friends.json index bccdf5dae..b672734aa 100644 --- a/locales/da/friends.json +++ b/locales/da/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/da/q.json b/locales/da/q.json index 23ed731dc..67032d63e 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/de/friends.json b/locales/de/friends.json index bccdf5dae..b672734aa 100644 --- a/locales/de/friends.json +++ b/locales/de/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/de/q.json b/locales/de/q.json index 4d96d5401..3dc8a4aa9 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/en/friends.json b/locales/en/friends.json index 9c0c9f267..b606dc5d6 100644 --- a/locales/en/friends.json +++ b/locales/en/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "View tournament", "friendsList.viewMatch": "View match", "friendsList.live": "Live", + "friendsList.inMatch": "Match", + "friendsList.nextMatch": "Next", + "friendsList.watchStream": "Watch stream", "friendsList.joinSendouQ": "Join SendouQ", "friendsList.deleteFriend": "Delete friend", "friendsList.deleteConfirm": "Delete {{name}} as a friend?", diff --git a/locales/en/q.json b/locales/en/q.json index 631e31f7d..2f3caa285 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "Loss", "match.timeline.out": "Out", "match.timeline.in": "In", - "match.timeline.live": "LIVE", + "match.timeline.ongoing": "ONGOING", "match.timeline.picked": "Picked", "match.timeline.explainer.picked": "Map picked by this team", "match.timeline.explainer.pick": "Map or mode picked", diff --git a/locales/es-ES/friends.json b/locales/es-ES/friends.json index 38898f201..fbce6b594 100644 --- a/locales/es-ES/friends.json +++ b/locales/es-ES/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 4d8d679c6..d8aa0e8a2 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/es-US/friends.json b/locales/es-US/friends.json index 38898f201..fbce6b594 100644 --- a/locales/es-US/friends.json +++ b/locales/es-US/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index bab31db12..81ab3834e 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/fr-CA/friends.json b/locales/fr-CA/friends.json index 38898f201..fbce6b594 100644 --- a/locales/fr-CA/friends.json +++ b/locales/fr-CA/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index d58d9e3ae..7183f7039 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/fr-EU/friends.json b/locales/fr-EU/friends.json index 38898f201..fbce6b594 100644 --- a/locales/fr-EU/friends.json +++ b/locales/fr-EU/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index fe5968e4a..b5bcc4216 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/he/friends.json b/locales/he/friends.json index 781eb0c23..4dc3d11aa 100644 --- a/locales/he/friends.json +++ b/locales/he/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/he/q.json b/locales/he/q.json index 70b98ad14..9ad6cea7b 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/it/friends.json b/locales/it/friends.json index 38898f201..fbce6b594 100644 --- a/locales/it/friends.json +++ b/locales/it/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/it/q.json b/locales/it/q.json index 8228f48cc..facb241c2 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/ja/friends.json b/locales/ja/friends.json index 710ed7c25..94a9f4972 100644 --- a/locales/ja/friends.json +++ b/locales/ja/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/ja/q.json b/locales/ja/q.json index 3341008a9..6f7601b88 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/ko/friends.json b/locales/ko/friends.json index 710ed7c25..94a9f4972 100644 --- a/locales/ko/friends.json +++ b/locales/ko/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index 4d96d5401..3dc8a4aa9 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/nl/friends.json b/locales/nl/friends.json index bccdf5dae..b672734aa 100644 --- a/locales/nl/friends.json +++ b/locales/nl/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 4d96d5401..3dc8a4aa9 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/pl/friends.json b/locales/pl/friends.json index 7aae6e996..fa6b96ad3 100644 --- a/locales/pl/friends.json +++ b/locales/pl/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 4d96d5401..3dc8a4aa9 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/pt-BR/friends.json b/locales/pt-BR/friends.json index 38898f201..fbce6b594 100644 --- a/locales/pt-BR/friends.json +++ b/locales/pt-BR/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 9dcece567..8d3b2f040 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/ru/friends.json b/locales/ru/friends.json index 7aae6e996..fa6b96ad3 100644 --- a/locales/ru/friends.json +++ b/locales/ru/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "", "friendsList.viewMatch": "", "friendsList.live": "", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "", "friendsList.deleteFriend": "", "friendsList.deleteConfirm": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index d6e9824c3..4f9f60b51 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "", "match.timeline.out": "", "match.timeline.in": "", - "match.timeline.live": "", + "match.timeline.ongoing": "", "match.timeline.picked": "", "match.timeline.explainer.picked": "", "match.timeline.explainer.pick": "", diff --git a/locales/zh/friends.json b/locales/zh/friends.json index 61cb33325..09060c07f 100644 --- a/locales/zh/friends.json +++ b/locales/zh/friends.json @@ -10,6 +10,9 @@ "friendsList.viewTournament": "查看赛事", "friendsList.viewMatch": "查看对局", "friendsList.live": "直播中", + "friendsList.inMatch": "", + "friendsList.nextMatch": "", + "friendsList.watchStream": "", "friendsList.joinSendouQ": "加入 SendouQ", "friendsList.deleteFriend": "删除好友", "friendsList.deleteConfirm": "确定要删除好友 {{name}} 吗?", diff --git a/locales/zh/q.json b/locales/zh/q.json index 00e243b3d..b05103da4 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -174,7 +174,7 @@ "match.timeline.loss": "负", "match.timeline.out": "下场", "match.timeline.in": "上场", - "match.timeline.live": "直播中", + "match.timeline.ongoing": "", "match.timeline.picked": "已选", "match.timeline.explainer.picked": "此场地由该队伍选择", "match.timeline.explainer.pick": "已选场地或模式", From 77b7e05b2cba8bc1fa5b66691881153635d547e9 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:21:57 +0300 Subject: [PATCH 04/13] Add Tenacity to Build Analyzer Closes #1471 --- .../build-analyzer/analyzer-constants.ts | 15 +++++- app/features/build-analyzer/analyzer-types.ts | 9 +++- .../build-analyzer/core/stats.test.ts | 52 +++++++++++++++++++ app/features/build-analyzer/core/stats.ts | 24 +++++++++ .../build-analyzer/routes/analyzer.tsx | 37 ++++++++++++- locales/da/analyzer.json | 3 ++ locales/de/analyzer.json | 3 ++ locales/en/analyzer.json | 3 ++ locales/es-ES/analyzer.json | 4 ++ locales/es-US/analyzer.json | 4 ++ locales/fr-CA/analyzer.json | 4 ++ locales/fr-EU/analyzer.json | 4 ++ locales/he/analyzer.json | 4 ++ locales/it/analyzer.json | 4 ++ locales/ja/analyzer.json | 1 + locales/ko/analyzer.json | 1 + locales/nl/analyzer.json | 3 ++ locales/pl/analyzer.json | 5 ++ locales/pt-BR/analyzer.json | 4 ++ locales/ru/analyzer.json | 5 ++ locales/zh/analyzer.json | 1 + 21 files changed, 187 insertions(+), 3 deletions(-) diff --git a/app/features/build-analyzer/analyzer-constants.ts b/app/features/build-analyzer/analyzer-constants.ts index 85db0e0a0..b97ed53c8 100644 --- a/app/features/build-analyzer/analyzer-constants.ts +++ b/app/features/build-analyzer/analyzer-constants.ts @@ -1,4 +1,4 @@ -import type { DamageType } from "./analyzer-types"; +import type { DamageType, TenacityPlayerDeficit } from "./analyzer-types"; export const MAX_LDE_INTENSITY = 21; @@ -109,3 +109,16 @@ export const MAX_AP = 57; export const MAIN_SLOT_AP = 10; export const SUB_SLOT_AP = 3; + +/** How many active players the opponent's team has more than the user's team */ +export const TENACITY_PLAYER_DEFICITS = [1, 2, 3] as const; + +/** Special points Tenacity passively grants per second. Unaffected by Special Charge Up. */ +export const TENACITY_SPECIAL_POINTS_PER_SECOND: Record< + TenacityPlayerDeficit, + number +> = { + 1: 3.26, + 2: 5.44, + 3: 7.59, +}; diff --git a/app/features/build-analyzer/analyzer-types.ts b/app/features/build-analyzer/analyzer-types.ts index 4ca6dc007..e5321d504 100644 --- a/app/features/build-analyzer/analyzer-types.ts +++ b/app/features/build-analyzer/analyzer-types.ts @@ -5,7 +5,10 @@ import type { SpecialWeaponId, SubWeaponId, } from "~/modules/in-game-lists/types"; -import type { DAMAGE_TYPE } from "./analyzer-constants"; +import type { + DAMAGE_TYPE, + TENACITY_PLAYER_DEFICITS, +} from "./analyzer-constants"; import type { SPECIAL_EFFECTS } from "./core/specialEffects"; import type { weaponParams } from "./data/weapon-params"; @@ -257,6 +260,8 @@ export interface FullInkTankOption { export type DamageType = (typeof DAMAGE_TYPE)[number]; +export type TenacityPlayerDeficit = (typeof TENACITY_PLAYER_DEFICITS)[number]; + export interface Damage { value: number; type: DamageType; @@ -284,6 +289,8 @@ export interface AnalyzedBuild { specialPoint: Stat; specialLost: Stat; specialLostSplattedByRP: Stat; + /** Seconds it takes Tenacity to fill the special gauge, keyed by how many active players the user's team is down. Only set if the build has Tenacity. */ + tenacitySecondsToSpecial?: Record; mainWeaponWhiteInkSeconds?: number; subWeaponWhiteInkSeconds: number; subWeaponInkConsumptionPercentage: Stat; diff --git a/app/features/build-analyzer/core/stats.test.ts b/app/features/build-analyzer/core/stats.test.ts index fbc265af2..8bd8b5fb0 100644 --- a/app/features/build-analyzer/core/stats.test.ts +++ b/app/features/build-analyzer/core/stats.test.ts @@ -90,6 +90,58 @@ describe("Analyze build", () => { ).toBeGreaterThan(analyzedJr.stats.subWeaponInkConsumptionPercentage.value); }); + test("Tenacity special charge time is only calculated with Tenacity in the build", () => { + const analyzed = buildStats({ + weaponSplId: 0, + hasTacticooler: false, + }); + + const analyzedWithTenacity = buildStats({ + weaponSplId: 0, + mainOnlyAbilities: ["T"], + hasTacticooler: false, + }); + + expect(analyzed.stats.tenacitySecondsToSpecial).toBeUndefined(); + expect(analyzedWithTenacity.stats.tenacitySecondsToSpecial).toBeDefined(); + }); + + test("Tenacity special charge time is not affected by Special Charge Up", () => { + const analyzed = buildStats({ + weaponSplId: 0, + mainOnlyAbilities: ["T"], + hasTacticooler: false, + }); + + const analyzedWithSCU = buildStats({ + weaponSplId: 0, + abilityPoints: new Map([["SCU", 57]]), + mainOnlyAbilities: ["T"], + hasTacticooler: false, + }); + + expect( + analyzedWithSCU.stats.specialPoint.value, + "Special Charge Up should lower the points needed for special", + ).toBeLessThan(analyzed.stats.specialPoint.value); + expect(analyzedWithSCU.stats.tenacitySecondsToSpecial).toEqual( + analyzed.stats.tenacitySecondsToSpecial, + ); + }); + + test("Tenacity fills the special gauge faster the more players the team is down", () => { + const analyzed = buildStats({ + weaponSplId: 0, + mainOnlyAbilities: ["T"], + hasTacticooler: false, + }); + + const secondsToSpecial = analyzed.stats.tenacitySecondsToSpecial!; + + expect(secondsToSpecial[2]).toBeLessThan(secondsToSpecial[1]); + expect(secondsToSpecial[3]).toBeLessThan(secondsToSpecial[2]); + }); + const subPowerApToQuickSuperJumpAp = new Map([ [0, 0], [3, 4], diff --git a/app/features/build-analyzer/core/stats.ts b/app/features/build-analyzer/core/stats.ts index 8fa6984a3..53507c8c8 100644 --- a/app/features/build-analyzer/core/stats.ts +++ b/app/features/build-analyzer/core/stats.ts @@ -28,6 +28,7 @@ import { assertUnreachable } from "~/utils/types"; import { DAMAGE_TYPE, RAINMAKER_SPEED_PENALTY_MODIFIER, + TENACITY_SPECIAL_POINTS_PER_SECOND, } from "../analyzer-constants"; import type { AbilityPoints, @@ -38,6 +39,7 @@ import type { SpecialWeaponParams, StatFunctionInput, SubWeaponParams, + TenacityPlayerDeficit, } from "../analyzer-types"; import { INK_CONSUME_TYPES } from "../analyzer-types"; import type { abilityValues as abilityValuesJson } from "../data/ability-values"; @@ -109,6 +111,7 @@ export function buildStats({ specialPoint: specialPoint(input), specialLost: specialLost(input), specialLostSplattedByRP: specialLost(input, true), + tenacitySecondsToSpecial: tenacitySecondsToSpecial(input), fullInkTankOptions: fullInkTankOptions(input), damages: damages(input), specialWeaponDamages: specialWeaponDamages(input), @@ -210,6 +213,27 @@ function specialPoint({ }; } +function tenacitySecondsToSpecial({ + mainWeaponParams, + mainOnlyAbilities, +}: StatFunctionInput): AnalyzedBuild["stats"]["tenacitySecondsToSpecial"] { + if (!mainOnlyAbilities.includes("T")) return; + + // Special Charge Up does not affect the rate Tenacity fills the gauge at + // so the unmodified amount of points needed is used here + const secondsToSpecial = (playerDeficit: TenacityPlayerDeficit) => + roundToNDecimalPlaces( + mainWeaponParams.SpecialPoint / + TENACITY_SPECIAL_POINTS_PER_SECOND[playerDeficit], + ); + + return { + 1: secondsToSpecial(1), + 2: secondsToSpecial(2), + 3: secondsToSpecial(3), + }; +} + const OWN_RESPAWN_PUNISHER_EXTRA_SPECIAL_LOST = 0.225; const ENEMY_RESPAWN_PUNISHER_EXTRA_SPECIAL_LOST = 0.15; function specialLost( diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index 83c710127..9d34820dc 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -22,6 +22,7 @@ import { Placeholder } from "~/components/Placeholder"; import { Table } from "~/components/Table"; import { WeaponSelect } from "~/components/WeaponSelect"; import { useUser } from "~/features/auth/core/user"; +import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; import { useHydrated } from "~/hooks/useHydrated"; import { abilitiesShort } from "~/modules/in-game-lists/abilities"; import type { @@ -62,6 +63,7 @@ import { damageTypeToWeaponType, MAX_AP, MAX_LDE_INTENSITY, + TENACITY_PLAYER_DEFICITS, } from "../analyzer-constants"; import { useAnalyzeBuild } from "../analyzer-hooks"; import type { @@ -154,6 +156,11 @@ function BuildAnalyzerPage() { const objectShredderSelected = build[2][0] === "OS" || build2[2][0] === "OS"; const stealthJumpSelected = build[2][0] === "SJ" || build2[2][0] === "SJ"; + // same for both builds as it only depends on the weapon + const tenacitySecondsToSpecial = + analyzed.stats.tenacitySecondsToSpecial ?? + analyzed2.stats.tenacitySecondsToSpecial; + const context = { isComparing: !buildIsEmpty(build) && !buildIsEmpty(build2), mainWeaponId, @@ -532,6 +539,27 @@ function BuildAnalyzerPage() { title={t("analyzer:stat.specialLostSplattedByRP")} suffix="%" /> + {tenacitySecondsToSpecial + ? TENACITY_PLAYER_DEFICITS.map((playerDeficit) => ( + + )) + : null} {analyzed.stats.specialDurationInSeconds && ( {/* always render this so it reserves space */}
- {!isStaticValue && ( + {isStaticValue ? ( + staticValueAbility ? ( + + ) : null + ) : ( <> Date: Tue, 28 Jul 2026 16:17:33 +0300 Subject: [PATCH 05/13] Migrate /art/new to SendouForm --- app/features/art/actions/art.new.server.ts | 110 ++--- app/features/art/art-image.server.ts | 43 ++ app/features/art/art-image.ts | 81 ++++ app/features/art/art-schemas.server.ts | 44 +- app/features/art/art-schemas.ts | 52 +++ .../components/ArtImageFormField.module.css | 3 + .../art/components/ArtImageFormField.tsx | 115 +++++ .../components/ArtTagsFormField.module.css | 11 + .../art/components/ArtTagsFormField.tsx | 152 +++++++ app/features/art/routes/art.new.tsx | 401 +++--------------- app/features/img-upload/image-bytes.server.ts | 53 +++ app/features/img-upload/image-field.server.ts | 41 +- app/features/img-upload/upload-constants.ts | 2 - app/form/parse.server.ts | 64 ++- app/utils/remix.server.ts | 74 ---- app/utils/zod.ts | 14 - docs/dev/forms.md | 10 +- e2e/art.spec.ts | 34 +- locales/da/art.json | 6 - locales/da/forms.json | 5 + locales/de/art.json | 6 - locales/de/forms.json | 5 + locales/en/art.json | 6 - locales/en/forms.json | 5 + locales/es-ES/art.json | 6 - locales/es-ES/forms.json | 5 + locales/es-US/art.json | 6 - locales/es-US/forms.json | 5 + locales/fr-CA/art.json | 6 - locales/fr-CA/forms.json | 5 + locales/fr-EU/art.json | 6 - locales/fr-EU/forms.json | 5 + locales/he/art.json | 6 - locales/he/forms.json | 5 + locales/it/art.json | 6 - locales/it/forms.json | 5 + locales/ja/art.json | 6 - locales/ja/forms.json | 5 + locales/ko/art.json | 6 - locales/ko/forms.json | 5 + locales/nl/art.json | 6 - locales/nl/forms.json | 5 + locales/pl/art.json | 6 - locales/pl/forms.json | 5 + locales/pt-BR/art.json | 6 - locales/pt-BR/forms.json | 5 + locales/ru/art.json | 6 - locales/ru/forms.json | 5 + locales/zh/art.json | 6 - locales/zh/forms.json | 5 + package.json | 1 - pnpm-lock.yaml | 22 - vite.config.ts | 1 - 53 files changed, 778 insertions(+), 726 deletions(-) create mode 100644 app/features/art/art-image.server.ts create mode 100644 app/features/art/art-image.ts create mode 100644 app/features/art/art-schemas.ts create mode 100644 app/features/art/components/ArtImageFormField.module.css create mode 100644 app/features/art/components/ArtImageFormField.tsx create mode 100644 app/features/art/components/ArtTagsFormField.module.css create mode 100644 app/features/art/components/ArtTagsFormField.tsx create mode 100644 app/features/img-upload/image-bytes.server.ts diff --git a/app/features/art/actions/art.new.server.ts b/app/features/art/actions/art.new.server.ts index bd90d2a9a..3a3794936 100644 --- a/app/features/art/actions/art.new.server.ts +++ b/app/features/art/actions/art.new.server.ts @@ -1,62 +1,57 @@ -import type { FileUpload } from "@remix-run/form-data-parser"; -import { nanoid } from "nanoid"; import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; +import * as R from "remeda"; import * as ArtRepository from "~/features/art/ArtRepository.server"; import { requireUser } from "~/features/auth/core/user.server"; -import { uploadStreamToS3 } from "~/features/img-upload/s3.server"; -import { ALLOWED_IMAGE_EXTENSIONS } from "~/features/img-upload/upload-constants"; import { notify } from "~/features/notifications/core/notify.server"; +import { parseFormData } from "~/form/parse.server"; import { requireRole } from "~/modules/permissions/guards.server"; import { dateToDatabaseTimestamp } from "~/utils/dates"; -import invariant from "~/utils/invariant"; -import { - errorToastIfFalsy, - parseFormData, - parseRequestPayload, - safeParseMultipartFormData, -} from "~/utils/remix.server"; +import { errorToastIfFalsy } from "~/utils/remix.server"; +import { toDBBoolean } from "~/utils/sql"; import { userArtPage } from "~/utils/urls"; -import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants"; -import { editArtSchema, newArtSchema } from "../art-schemas.server"; +import { ART_FORM_MAX_BODY_BYTES } from "../art-image"; +import { uploadArtImage } from "../art-image.server"; +import { artFormSchema } from "../art-schemas"; -export const action: ActionFunction = async ({ request, url }) => { +export const action: ActionFunction = async ({ request }) => { const user = requireUser(); requireRole("ARTIST"); - const searchParams = url.searchParams; - const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY); + const result = await parseFormData({ + request, + schema: artFormSchema, + maxBodyBytes: ART_FORM_MAX_BODY_BYTES, + }); - // updating logic - if (artIdRaw) { - const artId = Number(artIdRaw); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + const data = result.data; + const linkedUsers = R.unique( + data.linkedUsers.filter((userId) => typeof userId === "number"), + ); + + if (data.artId) { const userArts = await ArtRepository.findArtsByUserId(user.id, { includeTagged: false, }); - const existingArt = userArts.find((art) => art.id === artId); + const existingArt = userArts.find((art) => art.id === data.artId); errorToastIfFalsy(existingArt, "Art author is someone else"); - const data = await parseRequestPayload({ - request, - schema: editArtSchema, - }); - - const editedArtId = await ArtRepository.update(artId, { + const editedArtId = await ArtRepository.update(data.artId, { description: data.description, - isShowcase: data.isShowcase, - linkedUsers: data.linkedUsers, + isShowcase: toDBBoolean(data.isShowcase), + linkedUsers, tags: data.tags, }); const existingLinkedUserIds = existingArt.linkedUsers?.map((u) => u.id) ?? []; - const newLinkedUsers = data.linkedUsers.filter( - (userId) => !existingLinkedUserIds.includes(userId), - ); notify({ - userIds: newLinkedUsers, + userIds: R.difference(linkedUsers, existingLinkedUserIds), notification: { type: "TAGGED_TO_ART", meta: { @@ -67,61 +62,18 @@ export const action: ActionFunction = async ({ request, url }) => { }, }); } else { - const preDecidedFilename = `art-${nanoid()}-${Date.now()}`; - - const uploadHandler = async (fileUpload: FileUpload) => { - if ( - fileUpload.fieldName === "img" || - fileUpload.fieldName === "smallImg" - ) { - const ending = fileUpload.name.split(".").pop()?.toLowerCase(); - invariant( - ending && ending !== fileUpload.name, - `File missing extension: "${fileUpload.name}"`, - ); - invariant( - ALLOWED_IMAGE_EXTENSIONS.includes(ending), - `Invalid file extension: "${ending}"`, - ); - const newFilename = `${preDecidedFilename}${fileUpload.fieldName === "smallImg" ? "-small" : ""}.${ending}`; - - const uploadedFileLocation = await uploadStreamToS3( - fileUpload.stream(), - newFilename, - ); - return uploadedFileLocation; - } - return null; - }; - - const formData = await safeParseMultipartFormData( - request, - // 5MB - { maxFileSize: 5 * 1024 * 1024 }, - uploadHandler, - ); - const imgSrc = formData.get("img") as string | null; - invariant(imgSrc); - - const urlParts = imgSrc.split("/"); - const fileName = urlParts[urlParts.length - 1]; - invariant(fileName); - - const data = await parseFormData({ - formData, - schema: newArtSchema, - }); + errorToastIfFalsy(data.img?.type === "NEW", "Art image is missing"); const addedArt = await ArtRepository.insert({ description: data.description, - url: fileName, + url: await uploadArtImage(data.img), validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null, - linkedUsers: data.linkedUsers, + linkedUsers, tags: data.tags, }); notify({ - userIds: data.linkedUsers, + userIds: linkedUsers, notification: { type: "TAGGED_TO_ART", meta: { diff --git a/app/features/art/art-image.server.ts b/app/features/art/art-image.server.ts new file mode 100644 index 000000000..d227b2405 --- /dev/null +++ b/app/features/art/art-image.server.ts @@ -0,0 +1,43 @@ +import { basename } from "node:path"; +import { Readable } from "node:stream"; +import { dataUrlToImageBuffer } from "~/features/img-upload/image-bytes.server"; +import { uploadStreamToS3 } from "~/features/img-upload/s3.server"; +import { shortNanoid } from "~/utils/id"; +import invariant from "~/utils/invariant"; +import { previewUrl } from "./art-utils"; + +const ALLOWED_ART_IMAGE_EXTENSIONS = ["png", "jpeg", "webp"] as const; + +/** + * Uploads both assets a newly submitted art needs — the full image and its thumbnail, following + * the `-small.` convention {@link previewUrl} resolves — and returns the full image's + * file name to store on the art's image row. + */ +export async function uploadArtImage({ + dataUrl, + thumbnailDataUrl, +}: { + dataUrl: string; + thumbnailDataUrl: string; +}): Promise { + const image = dataUrlToImageBuffer(dataUrl, ALLOWED_ART_IMAGE_EXTENSIONS); + const thumbnail = dataUrlToImageBuffer( + thumbnailDataUrl, + ALLOWED_ART_IMAGE_EXTENSIONS, + ); + + invariant( + image.extension === thumbnail.extension, + "Art image and its thumbnail are of a different format", + ); + + const fileName = `art-${Date.now()}-${shortNanoid()}.${image.extension}`; + + const [uploadedLocation] = await Promise.all([ + uploadStreamToS3(Readable.from(image.buffer), fileName), + uploadStreamToS3(Readable.from(thumbnail.buffer), previewUrl(fileName)), + ]); + invariant(uploadedLocation, "Art image upload failed"); + + return basename(uploadedLocation); +} diff --git a/app/features/art/art-image.ts b/app/features/art/art-image.ts new file mode 100644 index 000000000..eeab10b57 --- /dev/null +++ b/app/features/art/art-image.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +/** + * Allowed prefixes for an art data URL. Unlike the generic `image()` form field, art keeps the + * uploaded image's own format instead of normalizing everything to webp. + */ +const ART_IMAGE_DATA_URL_PREFIX_REGEX = /^data:image\/(png|jpeg|webp);base64,/; + +/** + * Largest full-size art image accepted, decoded. Art is submitted at its original resolution and + * png keeps its detail losslessly, so this needs the same headroom the multipart upload flow used + * to allow. + */ +const ART_IMAGE_MAX_BYTES = 5 * 1024 * 1024; + +/** + * Largest art thumbnail accepted, decoded. The client caps the thumbnail's width to + * `ART.THUMBNAIL_WIDTH`, which lands well below this. + */ +const ART_THUMBNAIL_MAX_BYTES = 2 * 1024 * 1024; + +/** + * Ceiling for the whole art submit body. Fits both data URLs at their maximum plus the rest of the + * form's fields. + */ +export const ART_FORM_MAX_BODY_BYTES = + maxDataUrlLength(ART_IMAGE_MAX_BYTES + ART_THUMBNAIL_MAX_BYTES) + 100_000; + +/** Error shown when a picked image doesn't fit within the limits above. */ +export const ART_IMAGE_TOO_LARGE_ERROR = "forms:errors.imageTooLarge"; + +const artImageDataUrl = (maxBytes: number) => + z + .string() + .max(maxDataUrlLength(maxBytes), ART_IMAGE_TOO_LARGE_ERROR) + .regex(ART_IMAGE_DATA_URL_PREFIX_REGEX); + +/** + * JSON-serializable value of the art image form field. Art can't use the generic `image()` field: + * it preserves aspect ratio, keeps the original format and derives a separate thumbnail, which is + * why a `NEW` value carries two data URLs. An `EXISTING` value marks art whose image was already + * uploaded (only the preview url rides along, never bytes) — art images can't be swapped after + * upload. + */ +export const artImageValue = z + .union([ + z.object({ + type: z.literal("EXISTING"), + url: z.string(), + }), + z.object({ + type: z.literal("NEW"), + dataUrl: artImageDataUrl(ART_IMAGE_MAX_BYTES), + thumbnailDataUrl: artImageDataUrl(ART_THUMBNAIL_MAX_BYTES), + }), + ]) + .nullable(); + +export type ArtImageValue = z.infer; + +/** + * Does a freshly compressed art image exceed what the schema accepts? Lets the form field reject + * an oversized pick right away instead of only when the filled-out form is submitted. + */ +export function isArtImageTooLarge({ + dataUrl, + thumbnailDataUrl, +}: { + dataUrl: string; + thumbnailDataUrl: string; +}) { + return ( + dataUrl.length > maxDataUrlLength(ART_IMAGE_MAX_BYTES) || + thumbnailDataUrl.length > maxDataUrlLength(ART_THUMBNAIL_MAX_BYTES) + ); +} + +/** Length a base64 data URL encoding `bytes` decoded bytes can reach, `data:` prefix included. */ +function maxDataUrlLength(bytes: number) { + return Math.ceil(bytes / 3) * 4 + 32; +} diff --git a/app/features/art/art-schemas.server.ts b/app/features/art/art-schemas.server.ts index e8c92401b..015ba59b3 100644 --- a/app/features/art/art-schemas.server.ts +++ b/app/features/art/art-schemas.server.ts @@ -1,47 +1,5 @@ import { z } from "zod"; -import { - _action, - checkboxValueToDbBoolean, - dbBoolean, - falsyToNull, - id, - processMany, - removeDuplicates, - safeJSONParse, -} from "~/utils/zod"; -import { ART } from "./art-constants"; - -const description = z.preprocess( - falsyToNull, - z.string().max(ART.DESCRIPTION_MAX_LENGTH).nullable(), -); -const linkedUsers = z.preprocess( - processMany(safeJSONParse, removeDuplicates), - z.array(id).max(ART.LINKED_USERS_MAX_LENGTH), -); -const tags = z.preprocess( - safeJSONParse, - z - .array( - z.object({ - name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(), - id: id.optional(), - }), - ) - .max(ART.TAG_MAX_LENGTH), -); -export const newArtSchema = z.object({ - description, - linkedUsers, - tags, -}); - -export const editArtSchema = z.object({ - description, - linkedUsers, - tags, - isShowcase: z.preprocess(checkboxValueToDbBoolean, dbBoolean), -}); +import { _action, id } from "~/utils/zod"; const deleteArtSchema = z.object({ _action: _action("DELETE_ART"), diff --git a/app/features/art/art-schemas.ts b/app/features/art/art-schemas.ts new file mode 100644 index 000000000..eb0ea3a0d --- /dev/null +++ b/app/features/art/art-schemas.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; +import { + array, + customField, + idConstantOptional, + textAreaOptional, + toggle, + userSearchOptional, +} from "~/form/fields"; +import { id } from "~/utils/zod"; +import { ART } from "./art-constants"; +import { artImageValue } from "./art-image"; + +const artTags = z + .array( + z.object({ + name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(), + id: id.optional(), + }), + ) + .max(ART.TAGS_MAX_LENGTH); + +export const artFormSchema = z + .object({ + artId: idConstantOptional(), + img: customField({ initialValue: null }, artImageValue), + description: textAreaOptional({ + label: "labels.description", + maxLength: ART.DESCRIPTION_MAX_LENGTH, + }), + tags: customField({ initialValue: [] }, artTags), + linkedUsers: array({ + label: "labels.linkedUsers", + bottomText: "bottomTexts.linkedUsers", + max: ART.LINKED_USERS_MAX_LENGTH, + field: userSearchOptional({ label: "labels.user" }), + }), + isShowcase: toggle({ + label: "labels.showcase", + bottomText: "bottomTexts.showcase", + }), + }) + .superRefine((data, ctx) => { + // existing art keeps its image, new art must bring one + if (!data.artId && data.img?.type !== "NEW") { + ctx.addIssue({ + path: ["img"], + code: "custom", + message: "forms:errors.required", + }); + } + }); diff --git a/app/features/art/components/ArtImageFormField.module.css b/app/features/art/components/ArtImageFormField.module.css new file mode 100644 index 000000000..7922e577f --- /dev/null +++ b/app/features/art/components/ArtImageFormField.module.css @@ -0,0 +1,3 @@ +.preview { + max-width: 100%; +} diff --git a/app/features/art/components/ArtImageFormField.tsx b/app/features/art/components/ArtImageFormField.tsx new file mode 100644 index 000000000..4752113d3 --- /dev/null +++ b/app/features/art/components/ArtImageFormField.tsx @@ -0,0 +1,115 @@ +import clsx from "clsx"; +import Compressor from "compressorjs"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import type { CustomFieldRenderProps } from "~/form"; +import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper"; +import { logger } from "~/utils/logger"; +import { ART } from "../art-constants"; +import { + ART_IMAGE_TOO_LARGE_ERROR, + type ArtImageValue, + isArtImageTooLarge, +} from "../art-image"; +import { previewUrl } from "../art-utils"; +import styles from "./ArtImageFormField.module.css"; + +type ArtImageFormFieldProps = Omit< + CustomFieldRenderProps, + "name" +>; + +/** + * Image picker for art. Produces both derived assets the art pipeline needs — the full image with + * its aspect ratio and format preserved, and a thumbnail — as base64 data URLs so they can ride + * along in `SendouForm`'s single JSON submit. Art of already uploaded art can't be swapped, so an + * `EXISTING` value renders as a plain preview. + */ +export function ArtImageFormField({ + value, + onChange, + error, +}: ArtImageFormFieldProps) { + const id = React.useId(); + const [tooLargeError, setTooLargeError] = React.useState(); + const { t } = useTranslation(["common"]); + + if (value?.type === "EXISTING") { + return ; + } + + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + setTooLargeError(undefined); + + const uploadedFile = event.target.files?.[0]; + if (!uploadedFile) { + onChange(null); + return; + } + + try { + const [dataUrl, thumbnailDataUrl] = await Promise.all([ + compressToDataUrl(uploadedFile, {}), + compressToDataUrl(uploadedFile, { maxWidth: ART.THUMBNAIL_WIDTH }), + ]); + + if (isArtImageTooLarge({ dataUrl, thumbnailDataUrl })) { + setTooLargeError(ART_IMAGE_TOO_LARGE_ERROR); + onChange(null); + return; + } + + onChange({ type: "NEW", dataUrl, thumbnailDataUrl }); + } catch (err) { + logger.error(err); + onChange(null); + } + }; + + return ( + +
+ + {value ? ( + + ) : null} +
+
+ ); +} + +function compressToDataUrl( + file: File, + options: Compressor.Options, +): Promise { + return new Promise((resolve, reject) => { + new Compressor(file, { + ...options, + success(result) { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => + reject(new Error("Failed to read compressed image")); + reader.readAsDataURL(result); + }, + error: reject, + }); + }); +} diff --git a/app/features/art/components/ArtTagsFormField.module.css b/app/features/art/components/ArtTagsFormField.module.css new file mode 100644 index 000000000..36bd0a006 --- /dev/null +++ b/app/features/art/components/ArtTagsFormField.module.css @@ -0,0 +1,11 @@ +/* the minimal button variant keeps the full field height and its own font size, which breaks + the baseline when it sits inline in a line of helper text */ +.switcherButton { + height: auto; + font-size: inherit; +} + +/* the new tag input is stretched to the field width, so the button must keep its own */ +.addButton { + flex-shrink: 0; +} diff --git a/app/features/art/components/ArtTagsFormField.tsx b/app/features/art/components/ArtTagsFormField.tsx new file mode 100644 index 000000000..6e26f157e --- /dev/null +++ b/app/features/art/components/ArtTagsFormField.tsx @@ -0,0 +1,152 @@ +import { X } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { SendouButton } from "~/components/elements/Button"; +import type { CustomFieldRenderProps } from "~/form"; +import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper"; +import { ART } from "../art-constants"; +import styles from "./ArtTagsFormField.module.css"; +import { TagSelect } from "./TagSelect"; + +export type ArtTag = { name?: string; id?: number }; + +type ArtTagsFormFieldProps = Omit, "name"> & { + /** All tags that exist in the database, selectable without creating a new one. */ + existingTags: Array<{ id: number; name: string }>; +}; + +// note: not handling edge case where a tag was added by another user while this +// user was adding a new art with the same tag -> will crash +export function ArtTagsFormField({ + value, + onChange, + error, + existingTags, +}: ArtTagsFormFieldProps) { + const id = React.useId(); + const { t } = useTranslation(["art", "common"]); + const [creationMode, setCreationMode] = React.useState(false); + const [newTagValue, setNewTagValue] = React.useState(""); + + const handleAddNewTag = () => { + const normalizedNewTagValue = newTagValue + .trim() + // replace many whitespaces with one + .replace(/\s\s+/g, " ") + .toLowerCase(); + + if ( + normalizedNewTagValue.length === 0 || + normalizedNewTagValue.length > ART.TAG_MAX_LENGTH + ) { + return; + } + + const alreadyCreatedTag = existingTags.find( + (tag) => tag.name === normalizedNewTagValue, + ); + + if (alreadyCreatedTag) { + onChange([...value, alreadyCreatedTag]); + } else if (value.every((tag) => tag.name !== normalizedNewTagValue)) { + onChange([...value, { name: normalizedNewTagValue }]); + } + + setNewTagValue(""); + setCreationMode(false); + }; + + return ( + +
+ {value.length >= ART.TAGS_MAX_LENGTH ? ( +
+ {t("art:forms.tags.maxReached")} +
+ ) : creationMode ? ( + <> +
+ setNewTagValue(e.target.value)} + onKeyDown={(event) => { + if (event.code === "Enter") { + handleAddNewTag(); + } + }} + /> + + {t("common:actions.add")} + +
+
+ setCreationMode(false)} + > + {t("art:forms.tags.selectFromExisting")} + +
+ + ) : ( + <> + tag.id) + .filter((id) => id !== undefined)} + onSelectionChange={(tagName) => + onChange([ + ...value, + existingTags.find((tag) => tag.name === tagName)!, + ]) + } + /> +
+ {t("art:forms.tags.cantFindExisting")} + setCreationMode(true)} + > + {t("art:forms.tags.addNew")} + +
+ + )} + {value.length > 0 ? ( +
+ {value.map((tag) => ( +
+ {tag.name} + } + size="miniscule" + variant="minimal-destructive" + onPress={() => + onChange(value.filter((it) => it.name !== tag.name)) + } + /> +
+ ))} +
+ ) : null} +
+
+ ); +} diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx index 44f585e6c..79d935b8d 100644 --- a/app/features/art/routes/art.new.tsx +++ b/app/features/art/routes/art.new.tsx @@ -1,27 +1,20 @@ -import Compressor from "compressorjs"; -import { X } from "lucide-react"; -import { nanoid } from "nanoid"; -import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Form, useFetcher, useLoaderData } from "react-router"; +import { useLoaderData } from "react-router"; import { Alert } from "~/components/Alert"; -import { SendouButton } from "~/components/elements/Button"; -import { SendouSwitch } from "~/components/elements/Switch"; -import { UserSearch } from "~/components/elements/UserSearch"; import { FormMessage } from "~/components/FormMessage"; -import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; +import type { CustomFieldRenderProps } from "~/form"; +import { SendouForm } from "~/form/SendouForm"; import { useHasRole } from "~/modules/permissions/hooks"; -import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { artPage, navIconUrl } from "~/utils/urls"; import { metaTitle } from "../../../utils/remix"; import { action } from "../actions/art.new.server"; -import { ART } from "../art-constants"; -import { previewUrl } from "../art-utils"; -import { TagSelect } from "../components/TagSelect"; +import type { ArtImageValue } from "../art-image"; +import { artFormSchema } from "../art-schemas"; +import { ArtImageFormField } from "../components/ArtImageFormField"; +import { type ArtTag, ArtTagsFormField } from "../components/ArtTagsFormField"; import { loader } from "../loaders/art.new.server"; export { action, loader }; @@ -43,31 +36,9 @@ export const meta: MetaFunction = () => { export default function NewArtPage() { const data = useLoaderData(); - const [img, setImg] = React.useState(null); - const [smallImg, setSmallImg] = React.useState(null); - const { t } = useTranslation(["common", "art"]); - const ref = React.useRef(null); - const fetcher = useFetcher(); + const { t } = useTranslation(["art"]); const isArtist = useHasRole("ARTIST"); - const handleSubmit = () => { - const formData = new FormData(ref.current!); - - if (img) formData.append("img", img, img.name); - if (smallImg) formData.append("smallImg", smallImg, smallImg.name); - - fetcher.submit(formData, { - encType: "multipart/form-data", - method: "post", - }); - }; - - const submitButtonDisabled = () => { - if (fetcher.state !== "idle") return true; - - return (!img || !smallImg) && !data.art; - }; - if (!isArtist) { return (
@@ -76,321 +47,51 @@ export default function NewArtPage() { ); } + const isCurrentlyShowcase = Boolean(data.art?.isShowcase); + return (
-
- {t("art:forms.caveats")} - - - - - {data.art ? : null} -
- - {t("common:actions.save")} - -
- + user.id) ?? [], + isShowcase: isCurrentlyShowcase, + }} + > + {({ FormField }) => ( + <> + {t("art:forms.caveats")} + + {({ value, onChange, error }: CustomFieldRenderProps) => ( + void} + error={error} + /> + )} + + + + {({ value, onChange, error }: CustomFieldRenderProps) => ( + void} + error={error} + existingTags={data.tags} + /> + )} + + + {data.art ? ( + + ) : null} + + )} +
); } - -function ImageUpload({ - img, - setImg, - setSmallImg, -}: { - img: File | null; - setImg: (file: File | null) => void; - setSmallImg: (file: File | null) => void; -}) { - const data = useLoaderData(); - const { t } = useTranslation(["common"]); - const id = React.useId(); - - if (data.art) { - return ; - } - - return ( -
- - { - const uploadedFile = e.target.files?.[0]; - if (!uploadedFile) { - setImg(null); - return; - } - - new Compressor(uploadedFile, { - success(result) { - invariant(result instanceof Blob); - const file = new File([result], uploadedFile.name); - - setImg(file); - }, - error(err) { - logger.error(err.message); - }, - }); - - new Compressor(uploadedFile, { - maxWidth: ART.THUMBNAIL_WIDTH, - success(result) { - invariant(result instanceof Blob); - const file = new File([result], uploadedFile.name); - - setSmallImg(file); - }, - error(err) { - logger.error(err.message); - }, - }); - }} - /> - {img && } -
- ); -} - -function Description() { - const { t } = useTranslation(["art"]); - const data = useLoaderData(); - const [value, setValue] = React.useState(data.art?.description ?? ""); - const id = React.useId(); - - return ( -
- -