diff --git a/apps/web/src/lib/components/NotificationDot.svelte b/apps/web/src/lib/components/NotificationDot.svelte new file mode 100644 index 000000000..70f53f802 --- /dev/null +++ b/apps/web/src/lib/components/NotificationDot.svelte @@ -0,0 +1,58 @@ + + + + + + + + diff --git a/apps/web/src/lib/components/PwaLinks.svelte b/apps/web/src/lib/components/PwaLinks.svelte new file mode 100644 index 000000000..b80f97171 --- /dev/null +++ b/apps/web/src/lib/components/PwaLinks.svelte @@ -0,0 +1,20 @@ + + + + + + {#each PWA_SPLASH_SCREENS as { media, fileName } (fileName)} + + {/each} + diff --git a/apps/web/src/lib/components/TierPill.svelte b/apps/web/src/lib/components/TierPill.svelte new file mode 100644 index 000000000..822ff726a --- /dev/null +++ b/apps/web/src/lib/components/TierPill.svelte @@ -0,0 +1,191 @@ + + + + +
+ {displayName} +
+ + diff --git a/apps/web/src/lib/components/icons/DiscordIcon.svelte b/apps/web/src/lib/components/icons/DiscordIcon.svelte new file mode 100644 index 000000000..7acc0a71e --- /dev/null +++ b/apps/web/src/lib/components/icons/DiscordIcon.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/web/src/lib/components/icons/GitHubIcon.svelte b/apps/web/src/lib/components/icons/GitHubIcon.svelte new file mode 100644 index 000000000..4ba20fc3e --- /dev/null +++ b/apps/web/src/lib/components/icons/GitHubIcon.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/apps/web/src/lib/components/icons/PatreonIcon.svelte b/apps/web/src/lib/components/icons/PatreonIcon.svelte new file mode 100644 index 000000000..19fef7a37 --- /dev/null +++ b/apps/web/src/lib/components/icons/PatreonIcon.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/apps/web/src/lib/components/icons/TwitchIcon.svelte b/apps/web/src/lib/components/icons/TwitchIcon.svelte new file mode 100644 index 000000000..2d0ee8db9 --- /dev/null +++ b/apps/web/src/lib/components/icons/TwitchIcon.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/web/src/lib/components/layout/AnythingAdder.svelte b/apps/web/src/lib/components/layout/AnythingAdder.svelte new file mode 100644 index 000000000..d1d215263 --- /dev/null +++ b/apps/web/src/lib/components/layout/AnythingAdder.svelte @@ -0,0 +1,67 @@ + + +{#if user} + + {#snippet trigger(triggerProps)} + + {/snippet} + {#each items as item (item.id)} + + {#snippet icon()} + + {/snippet} + {dynamicMessage(`common_header_adder_${item.id}`)} + + {/each} + +{/if} diff --git a/apps/web/src/lib/components/layout/EventsList.svelte b/apps/web/src/lib/components/layout/EventsList.svelte new file mode 100644 index 000000000..77787f97b --- /dev/null +++ b/apps/web/src/lib/components/layout/EventsList.svelte @@ -0,0 +1,99 @@ + + +{#if events.length === 0} +
+ {m.front_sideNav_noEvents()} +
+{:else} + {#each groupedEvents as [dayKey, dayEvents] (dayKey)} +
+
+ {formatDayHeader(new Date(dayEvents[0].startsAt * 1000))} +
+ {#each dayEvents as event (`${event.type}-${event.id}`)} + + {eventTitle(event)} + + {/each} +
+ {/each} +{/if} + + diff --git a/apps/web/src/lib/components/layout/Footer.svelte b/apps/web/src/lib/components/layout/Footer.svelte new file mode 100644 index 000000000..d695637ae --- /dev/null +++ b/apps/web/src/lib/components/layout/Footer.svelte @@ -0,0 +1,258 @@ + + + + + diff --git a/apps/web/src/lib/components/layout/FriendMenu.svelte b/apps/web/src/lib/components/layout/FriendMenu.svelte new file mode 100644 index 000000000..f195d1f6d --- /dev/null +++ b/apps/web/src/lib/components/layout/FriendMenu.svelte @@ -0,0 +1,148 @@ + + + + + + {#snippet trigger(triggerProps)} + + {name} + + {/snippet} + + {#snippet icon()}{/snippet} + {m.friends_friendsList_viewUserPage()} + + {#if streamUrl} + + {#snippet icon()}{/snippet} + {m.friends_friendsList_watchStream()} + + {/if} + {#if activity?.type === "view-match"} + + {#snippet icon()}{/snippet} + {m.friends_friendsList_viewMatch()} + + {/if} + {#if activity?.type === "view-tournament"} + + {#snippet icon()}{/snippet} + {m.friends_friendsList_viewTournament()} + + {/if} + diff --git a/apps/web/src/lib/components/layout/GhostTabBar.svelte b/apps/web/src/lib/components/layout/GhostTabBar.svelte new file mode 100644 index 000000000..c631a8f3d --- /dev/null +++ b/apps/web/src/lib/components/layout/GhostTabBar.svelte @@ -0,0 +1,46 @@ + + + + + diff --git a/apps/web/src/lib/components/layout/Layout.svelte b/apps/web/src/lib/components/layout/Layout.svelte new file mode 100644 index 000000000..d41f65f93 --- /dev/null +++ b/apps/web/src/lib/components/layout/Layout.svelte @@ -0,0 +1,746 @@ + + +{#snippet siteLogoContent()} + S + ink +{/snippet} + +{#snippet siteTitle()} +
+ + {#each breadcrumbs as crumb (crumb.href)} + / + {#if page.url.pathname === crumb.href} + {@render pageIcon(crumb)} + {:else} + + {@render pageIcon(crumb)} + + {/if} + {/each} + {#if currentPageText} + {currentPageText} + {/if} +
+{/snippet} + +{#snippet pageIcon(crumb: Breadcrumb)} + {#if crumb.type === "IMAGE" && crumb.imgPath} +
+ +
+ {/if} +{/snippet} + +{#snippet sideNavFooterContent()} + + + +{/snippet} + +{#snippet sideNavChildren()} + + {#snippet icon()}{/snippet} + {#snippet action()} + {#if user} + + {m.common_actions_viewAll()} + + + {/if} + {/snippet} + {m.front_sideNav_myCalendar()} + + {#each events as event (`${event.type}-${event.id}`)} + + {#snippet subtitle()} + {#if hydrated} + {formatRelativeDate(event.startsAt)} + {:else} + + {/if} + {/snippet} + {event.scrimStatus === "booked" + ? m.front_sideNav_scrimVs({ opponent: event.name }) + : event.scrimStatus === "looking" + ? m.front_sideNav_lookingForScrim() + : event.scrimStatus === "requestPending" + ? m.front_sideNav_scrimRequestPending() + : event.name} + + {:else} +
+ {m.front_sideNav_noEvents()} +
+ {/each} + + + {#snippet icon()}{/snippet} + {#snippet action()} + {#if user} + {#if unseenFriendRequests > 0} + + {unseenFriendRequests} + + {/if} + + {m.common_actions_viewAll()} + + + {/if} + {/snippet} + {m.front_sideNav_friends()} + + {#each friends.slice(0, MAX_DESKTOP_FRIENDS) as friend (friend.id)} + + {:else} +
+ {user + ? m.front_sideNav_friends_noFriends() + : m.front_sideNav_friends_notLoggedIn()} +
+ {/each} + + + {#snippet icon()}{/snippet} + {m.front_sideNav_streams()} + + {#if streams.length === 0} +
+ {m.front_sideNav_noStreams()} +
+ {/if} + +{/snippet} + + + {#snippet top()}{@render siteTitle()}{/snippet} + {#snippet footer()}{@render sideNavFooterContent()}{/snippet} + {@render sideNavChildren()} + + +
+
+ +
+ + {#if !sideNavModalOpen && showUnseenDot} + + {/if} + {#if !sideNavModalOpen && unseenFriendRequests > 0} + + {unseenFriendRequests} + + {/if} +
+
+ + {#if sideNavCollapsed && showUnseenDot} + + {/if} + {#if sideNavCollapsed && unseenFriendRequests > 0} + + {unseenFriendRequests} + + {/if} +
+ + + +
+ {@render children()} +
+
+ +{#if sideNavModalOpen} + +
{ + if (event.target === event.currentTarget) sideNavModalOpen = false; + }} + > +
+ + {#snippet top()}{@render siteTitle()}{/snippet} + {#snippet footer()}{@render sideNavFooterContent()}{/snippet} + {@render sideNavChildren()} + +
+
+{/if} + + diff --git a/apps/web/src/lib/components/layout/ListButton.svelte b/apps/web/src/lib/components/layout/ListButton.svelte new file mode 100644 index 000000000..de9865dad --- /dev/null +++ b/apps/web/src/lib/components/layout/ListButton.svelte @@ -0,0 +1,145 @@ + + + + + diff --git a/apps/web/src/lib/components/layout/ListLink.svelte b/apps/web/src/lib/components/layout/ListLink.svelte new file mode 100644 index 000000000..e8a72af30 --- /dev/null +++ b/apps/web/src/lib/components/layout/ListLink.svelte @@ -0,0 +1,178 @@ + + + + {#if user} + + {:else if imageUrl} +
+ + {#if overlayIconUrl} + + {/if} +
+ {/if} +
+ {@render children()} + {#if subtitle || badge} +
+ {#if subtitle} + + {#if typeof subtitle === "string"}{subtitle}{:else}{@render subtitle()}{/if} + + {/if} + {#if typeof badge === "string"} + + {badge} + + {:else if badge} + {@render badge()} + {/if} +
+ {/if} +
+
+ + diff --git a/apps/web/src/lib/components/layout/LogInButtonContainer.svelte b/apps/web/src/lib/components/layout/LogInButtonContainer.svelte new file mode 100644 index 000000000..a7fb59727 --- /dev/null +++ b/apps/web/src/lib/components/layout/LogInButtonContainer.svelte @@ -0,0 +1,14 @@ + + +
+ {@render children()} +
diff --git a/apps/web/src/lib/components/layout/MobileNav.svelte b/apps/web/src/lib/components/layout/MobileNav.svelte new file mode 100644 index 000000000..4c7124331 --- /dev/null +++ b/apps/web/src/lib/components/layout/MobileNav.svelte @@ -0,0 +1,384 @@ + + +
+ {#if activePanel === "menu"} + + {/if} + + {#if activePanel === "friends"} + + {#snippet icon()}{/snippet} + {#if (sidebarData?.friends.length ?? 0) > 0} + {#each sidebarData?.friends ?? [] as friend (friend.id)} + + {/each} + {:else} +
+ {user + ? m.front_sideNav_friends_noFriends() + : m.front_sideNav_friends_notLoggedIn()} +
+ {/if} + + {m.common_actions_viewAll()} + + +
+ {/if} + + {#if activePanel === "tourneys"} + + {#snippet icon()}{/snippet} + + + {m.common_actions_viewAll()} + + + + {/if} + + {#if activePanel === "you" && user} + + {#snippet icon()}{/snippet} + + + {#if notifications} + + {/if} + + {/if} + + +
+ + diff --git a/apps/web/src/lib/components/layout/MobileNavMenuOverlay.svelte b/apps/web/src/lib/components/layout/MobileNavMenuOverlay.svelte new file mode 100644 index 000000000..313294bbc --- /dev/null +++ b/apps/web/src/lib/components/layout/MobileNavMenuOverlay.svelte @@ -0,0 +1,335 @@ + + +
+
+ +
+
+ + diff --git a/apps/web/src/lib/components/layout/MobilePanel.svelte b/apps/web/src/lib/components/layout/MobilePanel.svelte new file mode 100644 index 000000000..aff708b27 --- /dev/null +++ b/apps/web/src/lib/components/layout/MobilePanel.svelte @@ -0,0 +1,154 @@ + + +
+
+ +
+
+ + diff --git a/apps/web/src/lib/components/layout/MobileTab.svelte b/apps/web/src/lib/components/layout/MobileTab.svelte new file mode 100644 index 000000000..6a32119b0 --- /dev/null +++ b/apps/web/src/lib/components/layout/MobileTab.svelte @@ -0,0 +1,108 @@ + + + + + diff --git a/apps/web/src/lib/components/layout/SideNav.svelte b/apps/web/src/lib/components/layout/SideNav.svelte new file mode 100644 index 000000000..234e60e73 --- /dev/null +++ b/apps/web/src/lib/components/layout/SideNav.svelte @@ -0,0 +1,90 @@ + + + + + diff --git a/apps/web/src/lib/components/layout/SideNavFooter.svelte b/apps/web/src/lib/components/layout/SideNavFooter.svelte new file mode 100644 index 000000000..cdd374295 --- /dev/null +++ b/apps/web/src/lib/components/layout/SideNavFooter.svelte @@ -0,0 +1,95 @@ + + +
+ {@render children()} +
+ + diff --git a/apps/web/src/lib/components/layout/SideNavHeader.svelte b/apps/web/src/lib/components/layout/SideNavHeader.svelte new file mode 100644 index 000000000..0faf56c60 --- /dev/null +++ b/apps/web/src/lib/components/layout/SideNavHeader.svelte @@ -0,0 +1,75 @@ + + +
+ {#if icon} +
{@render icon()}
+ {/if} +

{@render children()}

+ {#if action} + {@render action()} + {/if} +
+ + diff --git a/apps/web/src/lib/components/layout/SideNavUserPanel.svelte b/apps/web/src/lib/components/layout/SideNavUserPanel.svelte new file mode 100644 index 000000000..97a655b77 --- /dev/null +++ b/apps/web/src/lib/components/layout/SideNavUserPanel.svelte @@ -0,0 +1,140 @@ + + +{#if user} + + + {user.username} + +
+ {#if notifications} + {#key page.url.pathname} +
+ {#if showUnseenDot} + + {/if} + + {#snippet trigger(triggerProps)} + + {/snippet} + + +
+ {/key} + {/if} + + + +
+{:else} +
+ +
+
+ + + +
+{/if} + + diff --git a/apps/web/src/lib/components/layout/StreamListItems.svelte b/apps/web/src/lib/components/layout/StreamListItems.svelte new file mode 100644 index 000000000..2104ba7f4 --- /dev/null +++ b/apps/web/src/lib/components/layout/StreamListItems.svelte @@ -0,0 +1,198 @@ + + +{#each streams as stream, i (stream.id)} + {@const upcoming = isUpcoming(stream)} + {@const prevStream = streams.at(i - 1)} + {@const showUpcomingDivider = + upcoming && prevStream && !isUpcoming(prevStream)} + {@const tournamentId = tournamentIdOf(stream)} + {@const tier = stream.tier ?? stream.tentativeTier} + {#snippet subtitle()} + {#if stream.peakXp} + + + {stream.peakXp} + + {:else if stream.subtitle} + {stream.subtitle} + {:else if upcoming} + {formatRelativeDate(stream.startsAt)} + {:else} + {formatDistanceToNowLocalized(databaseTimestampToDate(stream.startsAt))} + {/if} + {/snippet} + {#snippet badge()} +
+ {#if isLoggedIn && tournamentId !== null} + {@const saved = savedTournamentIds?.includes(tournamentId) ?? false} + + + {/if} + {#if tier} +
+ +
+ {/if} +
+ {/snippet} + {#if showUpcomingDivider} +
+ {m.front_sideNav_streams_upcoming()} +
+ {/if} + {#if upcoming} + + {stream.name} + + {:else} + + {stream.name} + + {/if} +{/each} + + diff --git a/apps/web/src/lib/components/layout/TopNavMenus.svelte b/apps/web/src/lib/components/layout/TopNavMenus.svelte new file mode 100644 index 000000000..3a6f659fc --- /dev/null +++ b/apps/web/src/lib/components/layout/TopNavMenus.svelte @@ -0,0 +1,355 @@ + + + + + { + if (openCategory !== null) openCategory = null; + }} +/> + + + + diff --git a/apps/web/src/lib/components/layout/TopRightButtons.svelte b/apps/web/src/lib/components/layout/TopRightButtons.svelte new file mode 100644 index 000000000..42511fabd --- /dev/null +++ b/apps/web/src/lib/components/layout/TopRightButtons.svelte @@ -0,0 +1,307 @@ + + +
+ {#if showSupport} + +
+ + + +
+ {/if} + {#if isLoggedIn} +
+ {#if showSearch} +
+ + +
+ {/if} +
+ +
+
+ +
+
+ {#if onChatToggle} +
+ + {#if chatUnreadCount} + {chatUnreadCount} + {/if} +
+ {/if} + {#if onChatModalToggle} +
+ + {#if chatUnreadCount} + {chatUnreadCount} + {/if} +
+ {/if} + {:else} + + + + {/if} +
+ + diff --git a/apps/web/src/lib/components/layout/layout-types.ts b/apps/web/src/lib/components/layout/layout-types.ts new file mode 100644 index 000000000..963f13c24 --- /dev/null +++ b/apps/web/src/lib/components/layout/layout-types.ts @@ -0,0 +1,84 @@ +/** + * Structural types of the sidebar payload the root layout serves. Shapes match + * `resolveSidebarData` on the server; declared here so presentational + * components do not import server modules. + */ + +export interface SidebarUser { + id: number; + username: string; + discordId: string; + discordAvatar: string | null; + customUrl: string | null; + customAvatarUrl: string | null; +} + +export interface SidebarEvent { + id: number; + name: string; + url: string; + logoUrl: string | null; + /** Whose avatar the event shows instead of a logo of its own. */ + user: SidebarUser | null; + startsAt: number; + type: "tournament" | "scrim"; + scrimStatus?: "booked" | "looking" | "requestPending"; +} + +export type FriendActivityType = + | "SENDOUQ_MATCH" + | "TOURNAMENT_MATCH" + | "TOURNAMENT_WAITING" + | "SENDOUQ" + | "TOURNAMENT_SUB"; + +export interface SidebarFriend { + id: number; + name: string; + discordId: string; + discordAvatar: string | null; + customAvatarUrl: string | null; + url: string; + subtitle: string; + badge: string; + activityType: FriendActivityType | null; + matchId: number | null; + tournamentId: number | null; + streamUrl: string | null; +} + +export interface SidebarStream { + id: string; + name: string; + imageUrl: string; + overlayIconUrl?: string; + url: string; + subtitle: string; + startsAt: number; + tier: number | null; + membersPerTeam?: number; + tentativeTier?: number; + peakXp?: number; + twitchUsername?: string; +} + +export interface SidebarData { + events: SidebarEvent[]; + friends: SidebarFriend[]; + streams: SidebarStream[]; + savedTournamentIds: number[]; + incomingFriendRequestIds: number[]; +} + +/** A notification row as served to the client, with link and icon resolved server-side. */ +export interface NotificationRow { + id: number; + type: string; + meta?: Record; + /** 0 = unseen, 1 = seen */ + seen: number; + createdAt: number; + pictureUrl?: string | null; + href: string; + navIcon: string; +} diff --git a/apps/web/src/lib/components/layout/nav-items.ts b/apps/web/src/lib/components/layout/nav-items.ts new file mode 100644 index 000000000..d5f33f2be --- /dev/null +++ b/apps/web/src/lib/components/layout/nav-items.ts @@ -0,0 +1,111 @@ +import { Config } from "#lib/config.ts"; + +export const navItems = [ + { + name: "settings", + url: "settings", + prefetch: true, + }, + Config.showLutiNavItem + ? { + name: "luti", + url: "luti", + prefetch: false, + } + : null, + { + name: "sendouq", + url: "q", + prefetch: false, + }, + { + name: "analyzer", + url: "analyzer", + prefetch: true, + }, + { + name: "comp-analyzer", + url: "comp-analyzer", + prefetch: true, + }, + { + name: "builds", + url: "builds", + prefetch: true, + }, + { + name: "object-damage-calculator", + url: "object-damage-calculator", + prefetch: true, + }, + { + name: "leaderboards", + url: "leaderboards", + prefetch: false, + }, + { + name: "scrims", + url: "scrims", + prefetch: false, + }, + { + name: "lfg", + url: "lfg", + prefetch: false, + }, + { + name: "plans", + url: "plans", + prefetch: false, + }, + { + name: "trophies", + url: "trophies", + prefetch: false, + }, + { + name: "calendar", + url: "calendar", + prefetch: false, + }, + { + name: "plus", + url: "plus/suggestions", + prefetch: false, + }, + { + name: "xsearch", + url: "xsearch", + prefetch: false, + }, + { + name: "articles", + url: "a", + prefetch: false, + }, + { + name: "vods", + url: "vods", + prefetch: false, + }, + { + name: "art", + url: "art", + prefetch: false, + }, + { + name: "tier-list-maker", + url: "tier-list-maker", + prefetch: false, + }, + { + name: "links", + url: "links", + prefetch: true, + }, + { + name: "maps", + url: "maps", + prefetch: false, + }, +].filter((item) => item !== null); diff --git a/apps/web/src/lib/components/pwa-splash-screens.ts b/apps/web/src/lib/components/pwa-splash-screens.ts new file mode 100644 index 000000000..5aa7fad10 --- /dev/null +++ b/apps/web/src/lib/components/pwa-splash-screens.ts @@ -0,0 +1,142 @@ +/** + * The apple-touch-startup-image matrix from the React app's PWALinks, + * extracted verbatim so the SSR head diff stays clean. + */ +export const PWA_SPLASH_SCREENS = [ + { + media: "screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)", + fileName: "iPhone_14_Pro_Max_landscape.png", + }, + { + media: "screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)", + fileName: "iPhone_14_Pro_landscape.png", + }, + { + media: "screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)", + fileName: "iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png", + }, + { + media: "screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)", + fileName: "iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png", + }, + { + media: "screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)", + fileName: "iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png", + }, + { + media: "screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)", + fileName: "iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png", + }, + { + media: "screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "iPhone_11__iPhone_XR_landscape.png", + }, + { + media: "screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)", + fileName: "iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png", + }, + { + media: "screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png", + }, + { + media: "screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png", + }, + { + media: "screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "12.9__iPad_Pro_landscape.png", + }, + { + media: "screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "11__iPad_Pro__10.5__iPad_Pro_landscape.png", + }, + { + media: "screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "10.9__iPad_Air_landscape.png", + }, + { + media: "screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "10.5__iPad_Air_landscape.png", + }, + { + media: "screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "10.2__iPad_landscape.png", + }, + { + media: "screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png", + }, + { + media: "screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)", + fileName: "8.3__iPad_Mini_landscape.png", + }, + { + media: "screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)", + fileName: "iPhone_14_Pro_Max_portrait.png", + }, + { + media: "screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)", + fileName: "iPhone_14_Pro_portrait.png", + }, + { + media: "screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)", + fileName: "iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png", + }, + { + media: "screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)", + fileName: "iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png", + }, + { + media: "screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)", + fileName: "iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png", + }, + { + media: "screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)", + fileName: "iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png", + }, + { + media: "screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "iPhone_11__iPhone_XR_portrait.png", + }, + { + media: "screen and (device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)", + fileName: "iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png", + }, + { + media: "screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png", + }, + { + media: "screen and (device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png", + }, + { + media: "screen and (device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "12.9__iPad_Pro_portrait.png", + }, + { + media: "screen and (device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "11__iPad_Pro__10.5__iPad_Pro_portrait.png", + }, + { + media: "screen and (device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "10.9__iPad_Air_portrait.png", + }, + { + media: "screen and (device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "10.5__iPad_Air_portrait.png", + }, + { + media: "screen and (device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "10.2__iPad_portrait.png", + }, + { + media: "screen and (device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png", + }, + { + media: "screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)", + fileName: "8.3__iPad_Mini_portrait.png", + }, +] as const; diff --git a/apps/web/src/lib/features/associations/associations-types.ts b/apps/web/src/lib/features/associations/associations-types.ts new file mode 100644 index 000000000..587614969 --- /dev/null +++ b/apps/web/src/lib/features/associations/associations-types.ts @@ -0,0 +1,23 @@ +export const ASSOCIATION = { + VIRTUAL_IDENTIFIERS: ["+1", "+2", "+3", "FRIENDS"] as const, + MAX_COUNT_REGULAR_USER: 3, + MAX_COUNT_SUPPORTER: 6, + MAX_ASSOCIATION_MEMBER_COUNT: 300, +}; + +export type AssociationVirtualIdentifier = + (typeof ASSOCIATION)["VIRTUAL_IDENTIFIERS"][number]; + +/** If number, an actual association id and if string then a virtual identifier */ +export type AssociationIdentifier = number | AssociationVirtualIdentifier; + +export interface AssociationVisibility { + /** Which association should see it */ + forAssociation: AssociationIdentifier; + notFoundInstructions?: Array<{ + /** When to expand the visibility */ + at: number; + /** To which association expand the visibility to? null indicates public */ + forAssociation: AssociationIdentifier | null; + }>; +} diff --git a/apps/web/src/lib/features/calendar/calendar-types.ts b/apps/web/src/lib/features/calendar/calendar-types.ts new file mode 100644 index 000000000..e09879d14 --- /dev/null +++ b/apps/web/src/lib/features/calendar/calendar-types.ts @@ -0,0 +1,44 @@ +import type { Tables } from "#lib/server/db/tables.ts"; +import type { CommonUser } from "#lib/server/kysely.ts"; + +interface CommonEvent { + id: number; + name: string; + teamsCount: number; + /** How many players in total are rostered in the counted teams */ + membersCount: number; + minMembersPerTeam: number; + logoUrl: string | null; + url: string; + /** Is the tournament ranked? If null, tournament is not hosted on sendou.ink */ + isRanked: boolean | null; + /** Tournament tier (1=X, 2=S+, 3=S, 4=A+, 5=A, 6=B+, 7=B, 8=C+, 9=C). Null if not tiered. */ + tier: number | null; + /** Tentative tier prediction based on series history. Displayed with ~ prefix. */ + tentativeTier: number | null; + modes: Array | null; + organization: { + name: string; + slug: string; + } | null; + /** User id of the author of the event */ + authorId: number; +} + +export interface ShowcaseCalendarEvent extends CommonEvent { + type: "showcase"; + startsAt: number; + /** Id of the organization the event belongs to, if any */ + organizationId: number | null; + /** Tournament is hidden from the public (test tournament) */ + hidden: boolean; + isFinalized: boolean; + firstPlacers: Array<{ + teamName: string; + logoUrl: string | null; + members: (CommonUser & { country: Tables["User"]["country"] })[]; + notShownMembersCount: number; + div: string | null; + }>; + hasVods?: boolean; +} diff --git a/apps/web/src/lib/features/core-streams/streams.server.ts b/apps/web/src/lib/features/core-streams/streams.server.ts new file mode 100644 index 000000000..743c27624 --- /dev/null +++ b/apps/web/src/lib/features/core-streams/streams.server.ts @@ -0,0 +1,39 @@ +import { cache } from "#lib/server/cache.ts"; +import type { TournamentTierNumber } from "#lib/features/tournament/tournament-types.ts"; + +export const COMBINED_STREAMS_KEY = "combined-streams"; + +export function clearCombinedStreamsCache() { + cache.delete(COMBINED_STREAMS_KEY); +} + +export type SidebarStream = { + id: string; + name: string; + imageUrl: string; + overlayIconUrl?: string; + url: string; + subtitle: string; + startsAt: number; + tier: TournamentTierNumber | null; + membersPerTeam?: number; + tentativeTier?: number; + peakXp?: number; + twitchUsername?: string; +}; + +/** + * Port note: the React app derives these from `RunningTournaments` (the + * tournament cluster's in-process state, fed by the Twitch sync routine). The + * tournament cluster migrates in a later wave; until then no live tournament + * streams exist in this app — matching the React app whenever Twitch + * credentials are absent (the differ/e2e environment). + */ +export function getLiveTournamentStreams(): SidebarStream[] { + return []; +} + +/** Lowercased Twitch usernames of all members and casters streaming a currently live tournament. */ +export function getLiveTournamentStreamerTwitchNames(): string[] { + return []; +} diff --git a/apps/web/src/lib/features/friends/FriendRepository.server.ts b/apps/web/src/lib/features/friends/FriendRepository.server.ts new file mode 100644 index 000000000..2f314a85c --- /dev/null +++ b/apps/web/src/lib/features/friends/FriendRepository.server.ts @@ -0,0 +1,130 @@ +import { type SelectQueryBuilder, sql } from "kysely"; +import type { DB } from "#lib/server/db/tables.ts"; +import { db } from "#lib/server/db/sql.ts"; +import { commonUserSelect } from "#lib/server/kysely.ts"; +import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts"; + +/** The user's friends and current teammates with their tournament LFG data attached. */ +export async function findByUserIdWithActivity(userId: number) { + const [friendRows, teamMemberRows] = await Promise.all([ + withLfgJoins( + db + .selectFrom("Friendship") + .innerJoin("User", (join) => + join.on((eb) => + eb.or([ + eb.and([ + eb("Friendship.userOneId", "=", userId), + eb("User.id", "=", eb.ref("Friendship.userTwoId")), + ]), + eb.and([ + eb("Friendship.userTwoId", "=", userId), + eb("User.id", "=", eb.ref("Friendship.userOneId")), + ]), + ]), + ), + ) + .where((eb) => + eb.or([ + eb("Friendship.userOneId", "=", userId), + eb("Friendship.userTwoId", "=", userId), + ]), + ), + ) + .select([ + "Friendship.id as friendshipId", + "Friendship.createdAt as friendshipCreatedAt", + ]) + .orderBy("Friendship.createdAt", "desc") + .execute(), + withLfgJoins( + db + .selectFrom("TeamMemberWithSecondary as myMembership") + .innerJoin("TeamMemberWithSecondary as otherMembership", (join) => + join + .onRef("otherMembership.teamId", "=", "myMembership.teamId") + .on("otherMembership.userId", "!=", userId), + ) + .innerJoin("User", "User.id", "otherMembership.userId") + .where("myMembership.userId", "=", userId), + ).execute(), + ]); + + return [ + ...friendRows, + ...teamMemberRows.map((row) => ({ + ...row, + friendshipId: null as number | null, + friendshipCreatedAt: null as number | null, + })), + ]; +} + +function withLfgJoins>(qb: QB) { + const nowTimestamp = dateToDatabaseTimestamp(new Date()); + + return (qb as SelectQueryBuilder>) + .leftJoin("TournamentTeamMember", (join) => + join + .onRef("TournamentTeamMember.userId", "=", "User.id") + .on("TournamentTeamMember.isLooking", "=", 1), + ) + .leftJoin( + "TournamentTeam", + "TournamentTeam.id", + "TournamentTeamMember.tournamentTeamId", + ) + .leftJoin("Tournament", (join) => + join + .onRef("Tournament.id", "=", "TournamentTeam.tournamentId") + .on((eb) => + eb.or([ + eb( + sql`json_extract("Tournament"."settings", '$.regClosesAt')`, + "is", + null, + ), + eb( + sql`json_extract("Tournament"."settings", '$.regClosesAt')`, + ">", + nowTimestamp, + ), + ]), + ), + ) + .leftJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id") + .leftJoin( + "CalendarEventDate", + "CalendarEventDate.eventId", + "CalendarEvent.id", + ) + .select((eb) => [ + ...commonUserSelect(eb), + "CalendarEvent.name as tournamentName", + "TournamentTeam.tournamentId", + "CalendarEventDate.startsAt as tournamentStartTime", + sql< + number | null + >`(SELECT COUNT(*) FROM "TournamentTeamMember" "ttm" WHERE "ttm"."tournamentTeamId" = "TournamentTeam"."id")`.as( + "teamMemberCount", + ), + sql< + number | null + >`json_extract("Tournament"."settings", '$.minMembersPerTeam')`.as( + "tournamentMinTeamSize", + ), + ]); +} + +/** Ids of pending friend requests the user has received. */ +export async function findPendingReceivedRequestIds( + receiverId: number, +): Promise { + const rows = await db + .selectFrom("FriendRequest") + .select("FriendRequest.id") + .where("FriendRequest.receiverId", "=", receiverId) + .execute(); + + return rows.map((row) => row.id); +} diff --git a/apps/web/src/lib/features/friends/friends-constants.ts b/apps/web/src/lib/features/friends/friends-constants.ts new file mode 100644 index 000000000..7500215cf --- /dev/null +++ b/apps/web/src/lib/features/friends/friends-constants.ts @@ -0,0 +1,40 @@ +export const FRIEND = { + MAX_PENDING_REQUESTS: 20, + PENDING_REQUEST_EXPIRES_IN_MONTHS: 1, +} as const; + +export const SENDOUQ_ACTIVITY_LABEL = "SendouQ"; + +export type FriendActivityType = + | "SENDOUQ_MATCH" + | "TOURNAMENT_MATCH" + | "TOURNAMENT_WAITING" + | "SENDOUQ" + | "TOURNAMENT_SUB"; + +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; +} + +export function friendActivitySortValue(type: FriendActivityType | null) { + if (type === "SENDOUQ") return 4; + if (type === "TOURNAMENT_SUB") return 3; + if (isInProgressFriendActivity(type)) return 2; + return 0; +} diff --git a/apps/web/src/lib/features/friends/friends-utils.server.ts b/apps/web/src/lib/features/friends/friends-utils.server.ts new file mode 100644 index 000000000..350ce72a2 --- /dev/null +++ b/apps/web/src/lib/features/friends/friends-utils.server.ts @@ -0,0 +1,116 @@ +import { + findOwnGroupLite, + FULL_GROUP_SIZE, + groupExpiryStatus, + type LiteGroup, +} from "#lib/features/sendouq/q-groups.server.ts"; +import { cachedStreams } from "#lib/features/sendouq-streams/streams.server.ts"; +import { twitchUrl } from "#lib/utils/urls.ts"; +import { + type FriendActivityType, + SENDOUQ_ACTIVITY_LABEL, +} from "./friends-constants.ts"; + +export interface FriendActivity { + type: FriendActivityType | null; + subtitle: string | null; + 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; +} + +/** + * 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 (an ongoing SendouQ match) over + * looking-for-members activity. + * + * Port note: tournament activity (`TOURNAMENT_MATCH`/`TOURNAMENT_WAITING`) + * needs the tournament cluster's in-process state and returns with that + * migration; until then a friend playing a tournament shows as inactive. + */ +export function resolveFriendActivity({ + friendId, + tournamentId, + tournamentName, + teamMemberCount, + tournamentMinTeamSize, + sendouQMatchStreams, + sendouQGroups, +}: { + friendId: number; + tournamentId: number | null; + tournamentName: string | null; + teamMemberCount: number | null; + tournamentMinTeamSize: number | null; + sendouQMatchStreams: ReadonlyMap; + sendouQGroups: LiteGroup[]; +}): FriendActivity { + const ownGroup = findOwnGroupLite(sendouQGroups, 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, + }; + } + + if ( + ownGroup && + ownGroup.memberUserIds.length < FULL_GROUP_SIZE && + groupExpiryStatus(ownGroup.latestActionAt) !== "EXPIRED" + ) { + return { + type: "SENDOUQ", + subtitle: SENDOUQ_ACTIVITY_LABEL, + badge: `${ownGroup.memberUserIds.length}/${FULL_GROUP_SIZE}`, + matchId: null, + tournamentId: null, + streamUrl: null, + }; + } + + if (tournamentName) { + return { + type: "TOURNAMENT_SUB", + subtitle: tournamentName, + badge: `${teamMemberCount ?? 1}/${tournamentMinTeamSize ?? FULL_GROUP_SIZE}`, + matchId: null, + tournamentId, + streamUrl: null, + }; + } + + return { + type: null, + subtitle: null, + badge: null, + matchId: null, + tournamentId: null, + streamUrl: null, + }; +} diff --git a/apps/web/src/lib/features/front-page/ShowcaseTournaments.server.ts b/apps/web/src/lib/features/front-page/ShowcaseTournaments.server.ts new file mode 100644 index 000000000..3603c2e06 --- /dev/null +++ b/apps/web/src/lib/features/front-page/ShowcaseTournaments.server.ts @@ -0,0 +1,343 @@ +import cachified from "@epic-web/cachified"; +import * as R from "remeda"; +import type { ShowcaseCalendarEvent } from "#lib/features/calendar/calendar-types.ts"; +import * as TournamentRepository from "#lib/features/tournament/TournamentRepository.server.ts"; +import { tournamentIsRanked } from "#lib/features/tournament/tournament-utils.ts"; +import { getTentativeTier } from "#lib/features/tournament-organization/tentativeTiers.server.ts"; +import { cache, IN_MILLISECONDS, ttl } from "#lib/server/cache.ts"; +import type { CommonUser } from "#lib/server/kysely.ts"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "#lib/utils/dates.ts"; +import { tournamentPage } from "#lib/utils/urls.ts"; + +/** + * Port note: `firstPlacers` entries skip the React app's Progression-based + * division labeling (the sidebar path only cares whether a tournament has + * results at all); the full shape returns with the front-page migration. + */ + +interface ShowcaseTournamentCollection { + participatingFor: ShowcaseCalendarEvent[]; + organizingFor: ShowcaseCalendarEvent[]; + showcase: ShowcaseCalendarEvent[]; + results: ShowcaseCalendarEvent[]; +} + +interface ParticipationInfo { + participants: Set; + organizers: Set; +} + +export async function upcomingTournaments(): Promise { + const tournaments = await cachedTournaments(); + return tournaments.upcoming; +} + +export async function categorizedTournamentsByUserId( + userId: number | null, +): Promise { + const tournaments = await cachedTournaments(); + const participation = await cachedParticipationInfo( + userId, + tournaments.upcoming, + ); + + return { + organizingFor: tournaments.upcoming.filter((tournament) => + participation.organizers.has(tournament.id), + ), + participatingFor: tournaments.upcoming.filter( + (tournament) => + !tournament.hidden && participation.participants.has(tournament.id), + ), + showcase: resolveShowcaseTournaments( + tournaments.upcoming.filter( + (tournament) => + !tournament.hidden && + !participation.organizers.has(tournament.id) && + !participation.participants.has(tournament.id), + ), + ), + results: tournaments.results, + }; +} + +let participationInfoMap: Map | null = + null; + +const emptyParticipationInfo = (): ParticipationInfo => ({ + participants: new Set(), + organizers: new Set(), +}); + +export function clearParticipationInfoMap() { + participationInfoMap = null; +} + +async function cachedParticipationInfo( + userId: number | null, + tournaments: ShowcaseCalendarEvent[], +): Promise { + if (!userId) { + return emptyParticipationInfo(); + } + + if (participationInfoMap) { + return participationInfoMap.get(userId) ?? emptyParticipationInfo(); + } + + const participation = await tournamentsToParticipationInfoMap(tournaments); + participationInfoMap = participation; + + return participation.get(userId) ?? emptyParticipationInfo(); +} + +const SHOWCASE_TOURNAMENTS_CACHE_KEY = "front-tournaments-list"; + +export const clearCachedTournaments = () => + cache.delete(SHOWCASE_TOURNAMENTS_CACHE_KEY); + +async function cachedTournaments() { + return cachified({ + key: SHOWCASE_TOURNAMENTS_CACHE_KEY, + cache, + ttl: ttl(IN_MILLISECONDS.TWO_HOURS), + async getFreshValue() { + const tournaments = await TournamentRepository.findAllForShowcase(); + const mapped = await Promise.all(tournaments.map(mapTournamentFromDB)); + + return deleteExtraResults(mapped); + }, + }); +} + +function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) { + const threeDaysAgo = databaseTimestampThreeDaysAgo(); + const nonResults = tournaments.filter( + (tournament) => + tournament.firstPlacers.length === 0 && + !tournament.isFinalized && + tournament.startsAt > threeDaysAgo, + ); + + const rankedResults = tournaments + .filter( + (tournament) => tournament.firstPlacers.length > 0 && tournament.isRanked, + ) + .sort((a, b) => showcaseScore(b) - showcaseScore(a)); + const nonRankedResults = tournaments + .filter( + (tournament) => + tournament.firstPlacers.length > 0 && !tournament.isRanked, + ) + .sort((a, b) => showcaseScore(b) - showcaseScore(a)); + + const rankedResultsToKeep = rankedResults.slice(0, 4); + // min 2, max 6 non ranked results + const nonRankedResultsToKeep = nonRankedResults.slice( + 0, + 6 - rankedResultsToKeep.length, + ); + + return { + results: [...rankedResultsToKeep, ...nonRankedResultsToKeep].sort( + (a, b) => b.startsAt - a.startsAt, + ), + upcoming: nonResults, + }; +} + +function resolveShowcaseTournaments( + tournaments: ShowcaseCalendarEvent[], +): ShowcaseCalendarEvent[] { + const happeningDuringNextWeek = tournaments.filter( + (tournament) => + tournament.startsAt > databaseTimestampSixHoursAgo() && + tournament.startsAt < databaseTimestampWeekFromNow(), + ); + const sorted = happeningDuringNextWeek.sort( + (a, b) => b.teamsCount - a.teamsCount, + ); + + const ranked = sorted.filter((tournament) => tournament.isRanked).slice(0, 3); + // min 3, max 6 non ranked + const nonRanked = sorted + .filter((tournament) => !tournament.isRanked) + .slice(0, 6 - ranked.length); + + return [...ranked, ...nonRanked].sort((a, b) => a.startsAt - b.startsAt); +} + +async function tournamentsToParticipationInfoMap( + tournaments: ShowcaseCalendarEvent[], +): Promise> { + const tournamentIds = tournaments.map((tournament) => tournament.id); + if (tournamentIds.length === 0) return new Map(); + + const tournamentsWithUsers = + await TournamentRepository.findRelatedUsersByTournamentIds(tournamentIds); + + const result: Map = new Map(); + + const addToMap = ( + userId: number, + tournamentId: number, + type: "participant" | "organizer", + ) => { + const participation = result.get(userId) ?? emptyParticipationInfo(); + + if (type === "participant") { + participation.participants.add(tournamentId); + } else if (type === "organizer") { + participation.organizers.add(tournamentId); + } + + result.set(userId, participation); + }; + + for (const tournament of tournamentsWithUsers) { + for (const { userId } of tournament.teamMembers) { + addToMap(userId, tournament.id, "participant"); + } + + for (const { userId } of tournament.staff) { + addToMap(userId, tournament.id, "organizer"); + } + + addToMap(tournament.authorId, tournament.id, "organizer"); + } + + return result; +} + +const MEMBERS_TO_SHOW = 5; + +async function mapTournamentFromDB( + tournament: TournamentRepository.ForShowcase, +): Promise { + const firstPlacers = resolveFirstPlacers(tournament); + + const tentativeTier = + tournament.tier === null && + tournament.organizationId !== null && + !tournament.firstPlacers.length + ? await getTentativeTier(tournament.organizationId, tournament.name) + : null; + + return { + type: "showcase", + url: tournamentPage(tournament.id), + id: tournament.id, + authorId: tournament.authorId, + organizationId: tournament.organizationId, + name: tournament.name, + startsAt: tournament.startsAt, + teamsCount: tournament.teamsCount, + membersCount: tournament.membersCount, + logoUrl: tournament.logoUrl, + organization: tournament.organization + ? { + name: tournament.organization.name, + slug: tournament.organization.slug, + } + : null, + isRanked: tournamentIsRanked({ + isSetAsRanked: tournament.settings.isRanked, + startsAt: databaseTimestampToDate(tournament.startsAt), + minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4, + isTest: tournament.settings.isTest ?? false, + }), + tier: tournament.tier ?? null, + tentativeTier, + hidden: Boolean(tournament.hidden), + isFinalized: Boolean(tournament.isFinalized), + minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4, + modes: null, + hasVods: (tournament.vodCount ?? 0) > 0, + firstPlacers, + }; +} + +type FirstPlacerRow = TournamentRepository.ForShowcase["firstPlacers"][number]; + +function resolveFirstPlacers( + tournament: TournamentRepository.ForShowcase, +): ShowcaseCalendarEvent["firstPlacers"] { + if (tournament.firstPlacers.length === 0) { + return []; + } + + if (tournament.firstPlacers.every((p) => p.div === null)) { + return [buildFirstPlacerEntry(tournament.firstPlacers, { withMembers: true })]; + } + + const byDiv = R.groupBy(tournament.firstPlacers, (p) => p.div ?? ""); + return Object.values(byDiv) + .map((rows) => buildFirstPlacerEntry(rows, { withMembers: false })) + .sort((a, b) => (a.div ?? "").localeCompare(b.div ?? "")); +} + +function buildFirstPlacerEntry( + rows: FirstPlacerRow[], + { withMembers }: { withMembers: boolean }, +): ShowcaseCalendarEvent["firstPlacers"][number] { + const first = rows[0]; + const members = withMembers + ? rows.slice(0, MEMBERS_TO_SHOW).map((row) => ({ + customUrl: row.customUrl, + customAvatarUrl: row.customAvatarUrl, + discordAvatar: row.discordAvatar, + discordId: row.discordId, + id: row.id, + username: row.username, + country: row.country, + })) + : []; + + return { + teamName: first.teamName, + logoUrl: first.teamLogoUrl ?? first.pickupAvatarUrl, + div: first.div, + members, + notShownMembersCount: + withMembers && rows.length > MEMBERS_TO_SHOW + ? rows.length - MEMBERS_TO_SHOW + : 0, + }; +} + +function databaseTimestampWeekFromNow() { + const now = new Date(); + + now.setDate(now.getDate() + 7); + + return dateToDatabaseTimestamp(now); +} + +function databaseTimestampThreeDaysAgo() { + const now = new Date(); + + now.setDate(now.getDate() - 3); + + return dateToDatabaseTimestamp(now); +} + +function databaseTimestampSixHoursAgo() { + const now = new Date(); + + now.setHours(now.getHours() - 6); + + return dateToDatabaseTimestamp(now); +} + +const TIER_BONUS_PER_STEP = 5; +function showcaseScore(tournament: ShowcaseCalendarEvent): number { + const tierBonus = + typeof tournament.tier === "number" + ? (10 - tournament.tier) * TIER_BONUS_PER_STEP + : 0; + + return tournament.teamsCount + tierBonus; +} diff --git a/apps/web/src/lib/features/front-page/front-page.remote.ts b/apps/web/src/lib/features/front-page/front-page.remote.ts new file mode 100644 index 000000000..a27bece52 --- /dev/null +++ b/apps/web/src/lib/features/front-page/front-page.remote.ts @@ -0,0 +1,6 @@ +import { query } from "$app/server"; +import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts"; + +export const getPatrons = query(async () => { + return UserRepository.findAllPatrons(); +}); diff --git a/apps/web/src/lib/features/live-streams/LiveStreamRepository.server.ts b/apps/web/src/lib/features/live-streams/LiveStreamRepository.server.ts new file mode 100644 index 000000000..da24afe04 --- /dev/null +++ b/apps/web/src/lib/features/live-streams/LiveStreamRepository.server.ts @@ -0,0 +1,21 @@ +import * as StreamRanking from "#lib/features/sidebar/StreamRanking.ts"; +import { db } from "#lib/server/db/sql.ts"; +import { commonUserSelect, peakXpOverallSql } from "#lib/server/kysely.ts"; + +/** Live streams of users with a high enough X Rank peak XP to show in the sidebar. */ +export function findXRankStreams() { + return db + .selectFrom("LiveStream") + .innerJoin("User", "User.twitch", "LiveStream.twitch") + .innerJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id") + .where(peakXpOverallSql(), ">=", StreamRanking.minXpForStreamToBeShown()) + .where("LiveStream.twitch", "is not", null) + .select((eb) => [ + ...commonUserSelect(eb), + peakXpOverallSql().as("peakXp"), + "LiveStream.viewerCount", + "LiveStream.thumbnailUrl", + "LiveStream.twitch as twitchUsername", + ]) + .execute(); +} diff --git a/apps/web/src/lib/features/notifications/NotificationRepository.server.ts b/apps/web/src/lib/features/notifications/NotificationRepository.server.ts new file mode 100644 index 000000000..0f2e0941c --- /dev/null +++ b/apps/web/src/lib/features/notifications/NotificationRepository.server.ts @@ -0,0 +1,31 @@ +import { db } from "#lib/server/db/sql.ts"; +import { NOTIFICATIONS } from "./notifications-constants.ts"; +import type { Notification } from "./notifications-types.ts"; + +/** The user's newest notifications with their seen status, newest first. */ +export function findByUserId( + userId: number, + { limit }: { limit?: number } = {}, +) { + return db + .selectFrom("NotificationUser") + .innerJoin( + "Notification", + "Notification.id", + "NotificationUser.notificationId", + ) + .select([ + "Notification.id", + "Notification.createdAt", + "NotificationUser.seen", + "Notification.type", + "Notification.meta", + "Notification.pictureUrl", + ]) + .where("NotificationUser.userId", "=", userId) + .limit(limit ?? NOTIFICATIONS.MAX_SHOWN) + .orderBy("Notification.id", "desc") + .execute() as Promise< + Array + >; +} diff --git a/apps/web/src/lib/features/notifications/components/NotificationContent.svelte b/apps/web/src/lib/features/notifications/components/NotificationContent.svelte new file mode 100644 index 000000000..602bffb8d --- /dev/null +++ b/apps/web/src/lib/features/notifications/components/NotificationContent.svelte @@ -0,0 +1,125 @@ + + +

+ + {m.common_notifications_title()} +

+
+{#if notifications.length === 0} +
+ {m.common_notifications_empty()} +
+{:else} +
+ {#each notifications as notification, i (notification.id)} + + {#if i !== notifications.length - 1} +
+ {/if} + {/each} +
+{/if} +{#if notifications.length === PEEK_COUNT} + +{/if} + + diff --git a/apps/web/src/lib/features/notifications/components/NotificationItem.svelte b/apps/web/src/lib/features/notifications/components/NotificationItem.svelte new file mode 100644 index 000000000..07272b5b6 --- /dev/null +++ b/apps/web/src/lib/features/notifications/components/NotificationItem.svelte @@ -0,0 +1,123 @@ + + + +
+ {#if unseen} +
+ {/if} + {#if notification.pictureUrl} + Notification + {:else} + + {/if} +
+
{text}
+
+ {formatDistanceToNowLocalized( + databaseTimestampToDate(notification.createdAt), + )} +
+
+ + diff --git a/apps/web/src/lib/features/notifications/notifications-constants.ts b/apps/web/src/lib/features/notifications/notifications-constants.ts new file mode 100644 index 000000000..f5554a1c8 --- /dev/null +++ b/apps/web/src/lib/features/notifications/notifications-constants.ts @@ -0,0 +1,4 @@ +export const NOTIFICATIONS = { + PEEK_COUNT: 6, + MAX_SHOWN: 100, +}; diff --git a/apps/web/src/lib/features/notifications/notifications-types.ts b/apps/web/src/lib/features/notifications/notifications-types.ts new file mode 100644 index 000000000..79f2ed06c --- /dev/null +++ b/apps/web/src/lib/features/notifications/notifications-types.ts @@ -0,0 +1,115 @@ +export type Notification = + | NotificationItem< + "SQ_ADDED_TO_GROUP", + { + adderUsername: string; + } + > + | NotificationItem<"SQ_READY_CHECK"> + | NotificationItem< + "SQ_NEW_MATCH", + { + matchId: number; + } + > + | NotificationItem< + "TO_ADDED_TO_TEAM", + { + tournamentId: number; + tournamentName: string; + adderUsername: string; + teamName: string; + tournamentTeamId: number; + } + > + | NotificationItem< + "TO_BRACKET_STARTED", + { + tournamentId: number; + bracketIdx: number; + bracketName: string; + tournamentName: string; + } + > + | NotificationItem< + "TO_CHECK_IN_OPENED", + { + tournamentId: number; + tournamentName: string; + } + > + | NotificationItem< + "TO_TEST_CREATED", + { + tournamentId: number; + tournamentName: string; + } + > + | NotificationItem<"BADGE_ADDED", { badgeName: string; badgeId: number }> + | NotificationItem< + "BADGE_MANAGER_ADDED", + { badgeName: string; badgeId: number } + > + | NotificationItem< + "TROPHY_SUBMITTED", + { trophyName: string; submitterUsername: string } + > + | NotificationItem< + "TROPHY_SUBMISSION_ACCEPTED", + { trophyName: string; trophyId: number } + > + | NotificationItem<"TROPHY_SUBMISSION_DECLINED", { trophyName: string }> + | NotificationItem< + "PLUS_VOTING_STARTED", + { + seasonNth: number; + } + > + | NotificationItem<"PLUS_SUGGESTION_ADDED", { tier: number }> + | NotificationItem< + "TAGGED_TO_ART", + { adderUsername: string; adderDiscordId: string; artId: number } + > + | NotificationItem<"SEASON_STARTED", { seasonNth: number }> + | NotificationItem< + "SCRIM_NEW_REQUEST", + { fromUserId: number; fromUsername: string; scrimPostId: number } + > + | NotificationItem< + "SCRIM_SCHEDULED", + { id: number; opponentTeamName: string } + > + | NotificationItem<"SCRIM_CANCELED", { id: number; opponentTeamName: string }> + | NotificationItem< + "SCRIM_STARTING_SOON", + { id: number; opponentTeamName: string } + > + | NotificationItem<"SCRIM_AUTO_DELETED", { at: number }> + | NotificationItem<"COMMISSIONS_CLOSED", { discordId: string }> + | NotificationItem< + "FRIEND_REQUEST_RECEIVED", + { senderId: number; senderUsername: string } + > + | NotificationItem< + "TO_LIKE_RECEIVED", + { + tournamentId: number; + tournamentName: string; + likerUsername: string; + } + > + | NotificationItem< + "TO_LIKE_ACCEPTED", + { + tournamentId: number; + tournamentName: string; + accepterUsername: string; + } + >; + +type NotificationItem< + T extends string, + M extends Record | undefined = undefined, +> = M extends undefined + ? { type: T; pictureUrl?: string } + : { type: T; meta: M; pictureUrl?: string }; diff --git a/apps/web/src/lib/features/notifications/notifications-utils.ts b/apps/web/src/lib/features/notifications/notifications-utils.ts new file mode 100644 index 000000000..84db12d2f --- /dev/null +++ b/apps/web/src/lib/features/notifications/notifications-utils.ts @@ -0,0 +1,126 @@ +import { assertUnreachable } from "@sendou/utils/types"; +import type { NotificationRow } from "#lib/components/layout/layout-types.ts"; +import { FRIENDS_PAGE, SENDOUQ_PAGE, sendouQMatchPage, userPage } from "#lib/utils/urls.ts"; +import type { Notification } from "./notifications-types.ts"; + +const NEW_TROPHY_PAGE = "/trophies/new"; +const PLUS_VOTING_PAGE = "/plus/voting"; +const SENDOUQ_READY_PAGE = "/q/ready"; + +/** Values the notification's title and text interpolate. Some notification types have none. */ +export const notificationMeta = (notification: Notification) => + "meta" in notification ? notification.meta : undefined; + +export const notificationNavIcon = (type: Notification["type"]) => { + switch (type) { + case "BADGE_ADDED": + case "BADGE_MANAGER_ADDED": + return "badges"; + case "TROPHY_SUBMITTED": + case "TROPHY_SUBMISSION_ACCEPTED": + case "TROPHY_SUBMISSION_DECLINED": + return "trophies"; + case "PLUS_SUGGESTION_ADDED": + case "PLUS_VOTING_STARTED": + return "plus"; + case "SQ_ADDED_TO_GROUP": + case "SQ_NEW_MATCH": + case "SQ_READY_CHECK": + case "SEASON_STARTED": + return "sendouq"; + case "TAGGED_TO_ART": + case "COMMISSIONS_CLOSED": + return "art"; + case "TO_ADDED_TO_TEAM": + case "TO_BRACKET_STARTED": + case "TO_CHECK_IN_OPENED": + case "TO_TEST_CREATED": + case "TO_LIKE_RECEIVED": + case "TO_LIKE_ACCEPTED": + return "medal"; + case "SCRIM_NEW_REQUEST": + case "SCRIM_SCHEDULED": + case "SCRIM_CANCELED": + case "SCRIM_STARTING_SOON": + case "SCRIM_AUTO_DELETED": + return "scrims"; + case "FRIEND_REQUEST_RECEIVED": + return "sendou_love"; + default: + assertUnreachable(type); + } +}; + +export const notificationLink = (notification: Notification) => { + switch (notification.type) { + case "BADGE_ADDED": + case "BADGE_MANAGER_ADDED": + return `/badges/${notification.meta.badgeId}`; + case "TROPHY_SUBMITTED": + case "TROPHY_SUBMISSION_DECLINED": + return NEW_TROPHY_PAGE; + case "TROPHY_SUBMISSION_ACCEPTED": + return `/trophies/${notification.meta.trophyId}`; + case "PLUS_SUGGESTION_ADDED": + return `/plus/suggestions?tier=${notification.meta.tier}`; + case "PLUS_VOTING_STARTED": + return PLUS_VOTING_PAGE; + case "SEASON_STARTED": + case "SQ_ADDED_TO_GROUP": + return SENDOUQ_PAGE; + case "SQ_NEW_MATCH": + return sendouQMatchPage(notification.meta.matchId); + case "SQ_READY_CHECK": + return SENDOUQ_READY_PAGE; + case "TAGGED_TO_ART": + return `/u/${notification.meta.adderDiscordId}/art?source=MADE-BY&big=${notification.meta.artId}`; + case "TO_ADDED_TO_TEAM": + return `/to/${notification.meta.tournamentId}/teams/${notification.meta.tournamentTeamId}`; + case "TO_BRACKET_STARTED": + return `/to/${notification.meta.tournamentId}/brackets${typeof notification.meta.bracketIdx === "number" ? `?idx=${notification.meta.bracketIdx}` : ""}`; + case "TO_TEST_CREATED": + case "TO_CHECK_IN_OPENED": + return `/to/${notification.meta.tournamentId}/register`; + case "SCRIM_NEW_REQUEST": + case "SCRIM_AUTO_DELETED": + return "/scrims"; + case "SCRIM_CANCELED": + case "SCRIM_SCHEDULED": + case "SCRIM_STARTING_SOON": + return `/scrims/${notification.meta.id}`; + case "COMMISSIONS_CLOSED": + return `${userPage({ discordId: notification.meta.discordId })}/edit`; + case "FRIEND_REQUEST_RECEIVED": + return FRIENDS_PAGE; + case "TO_LIKE_RECEIVED": + case "TO_LIKE_ACCEPTED": + return `/to/${notification.meta.tournamentId}/looking`; + default: + assertUnreachable(notification); + } +}; + +type LoadedNotification = Notification & { + id: number; + createdAt: number; + seen: number; + pictureUrl?: string | null; +}; + +/** Maps repository notifications to the presentational rows the bell popover renders. */ +export function toNotificationRows( + notifications: LoadedNotification[], +): NotificationRow[] { + return notifications.map((notification) => ({ + id: notification.id, + type: notification.type, + meta: notificationMeta(notification) as + | Record + | undefined, + seen: notification.seen, + createdAt: notification.createdAt, + pictureUrl: notification.pictureUrl, + href: notificationLink(notification), + navIcon: notificationNavIcon(notification.type), + })); +} diff --git a/apps/web/src/lib/features/notifications/notifications.remote.ts b/apps/web/src/lib/features/notifications/notifications.remote.ts new file mode 100644 index 000000000..d708fcecb --- /dev/null +++ b/apps/web/src/lib/features/notifications/notifications.remote.ts @@ -0,0 +1,20 @@ +import { query } from "$app/server"; +import { getUser } from "#lib/features/auth/user.server.ts"; +import * as NotificationRepository from "./NotificationRepository.server.ts"; +import { NOTIFICATIONS } from "./notifications-constants.ts"; + +/** + * The notification peek shown in the bell popover. Same shape as the React + * app's `GET /api/notifications` resource route. + */ +export const getNotifications = query(async () => { + const user = getUser(); + + return { + notifications: user + ? await NotificationRepository.findByUserId(user.id, { + limit: NOTIFICATIONS.PEEK_COUNT, + }) + : undefined, + }; +}); diff --git a/apps/web/src/lib/features/scrims/Scrim.ts b/apps/web/src/lib/features/scrims/Scrim.ts new file mode 100644 index 000000000..b93dcfff0 --- /dev/null +++ b/apps/web/src/lib/features/scrims/Scrim.ts @@ -0,0 +1,32 @@ +import type { LutiDiv, ScrimPost } from "./scrims-types.ts"; + +/** Returns true if the original poster has accepted any of the requests. */ +export function isAccepted(post: ScrimPost) { + return post.requests.some((request) => request.isAccepted); +} + +/** Returns true if the user is participating in the scrim, either in the original post users list or the request. */ +export function isParticipating(post: ScrimPost, userId: number) { + return ( + post.requests.some((request) => + request.users.some((user) => user.id === userId), + ) || post.users.some((user) => user.id === userId) + ); +} + +/** + * Returns the actual start time of the scrim. + * When the post has a time range (rangeEndsAt is set), returns the accepted request's specific time if available. + * Otherwise returns the post's start time. + */ +export function getStartTime(post: ScrimPost): number { + const acceptedRequest = post.requests.find((r) => r.isAccepted); + return acceptedRequest?.startsAt ?? post.startsAt; +} + +/** Converts a `ScrimPost.maxDiv`/`minDiv` database value into its LUTI div label. */ +export const parseLutiDiv = (div: number): LutiDiv => { + if (div === 0) return "X"; + + return String(div) as LutiDiv; +}; diff --git a/apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts b/apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts new file mode 100644 index 000000000..f6cfd7f54 --- /dev/null +++ b/apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts @@ -0,0 +1,312 @@ +import type { Unwrapped } from "@sendou/utils/types"; +import { db } from "#lib/server/db/sql.ts"; +import { + type CommonUser, + commonUserSelect, + concatUserSubmittedImagePrefix, + jsonArrayFrom, + jsonBuildObject, + tournamentLogoWithDefault, +} from "#lib/server/kysely.ts"; +import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts"; +import * as Scrim from "./Scrim.ts"; +import type { ScrimPost, ScrimPostUser } from "./scrims-types.ts"; + +const baseFindQuery = db + .selectFrom("ScrimPost") + .leftJoin("Team", "ScrimPost.teamId", "Team.id") + .leftJoin("UserSubmittedImage", "Team.avatarImgId", "UserSubmittedImage.id") + .leftJoin( + "CalendarEvent", + "ScrimPost.mapsTournamentId", + "CalendarEvent.tournamentId", + ) + .select((eb) => [ + "ScrimPost.id", + "ScrimPost.startsAt", + "ScrimPost.rangeEndsAt", + "ScrimPost.createdAt", + "ScrimPost.visibility", + "ScrimPost.maxDiv", + "ScrimPost.minDiv", + "ScrimPost.text", + "ScrimPost.maps", + "ScrimPost.mapsTournamentId", + "ScrimPost.managedByAnyone", + "ScrimPost.canceledAt", + "ScrimPost.canceledByUserId", + "ScrimPost.cancelReason", + "ScrimPost.isScheduledForFuture", + jsonBuildObject({ + name: eb.ref("Team.name"), + customUrl: eb.ref("Team.customUrl"), + avatarUrl: concatUserSubmittedImagePrefix( + eb.ref("UserSubmittedImage.url"), + ), + }).as("team"), + jsonBuildObject({ + id: eb.ref("CalendarEvent.tournamentId"), + name: eb.ref("CalendarEvent.name"), + avatarUrl: tournamentLogoWithDefault(eb), + }).as("mapsTournament"), + jsonArrayFrom( + eb + .selectFrom("ScrimPostUser") + .innerJoin("User", "ScrimPostUser.userId", "User.id") + .select((eb) => [ + ...commonUserSelect(eb), + "User.inGameName", + "ScrimPostUser.isOwner", + ]) + .whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id"), + ).as("users"), + jsonArrayFrom( + eb + .selectFrom("ScrimPostRequest") + .leftJoin("Team", "ScrimPostRequest.teamId", "Team.id") + .leftJoin( + "UserSubmittedImage", + "Team.avatarImgId", + "UserSubmittedImage.id", + ) + .select((innerEb) => [ + "ScrimPostRequest.id", + "ScrimPostRequest.isAccepted", + "ScrimPostRequest.createdAt", + "ScrimPostRequest.message", + "ScrimPostRequest.startsAt", + jsonBuildObject({ + name: innerEb.ref("Team.name"), + customUrl: innerEb.ref("Team.customUrl"), + avatarUrl: concatUserSubmittedImagePrefix( + innerEb.ref("UserSubmittedImage.url"), + ), + }).as("team"), + jsonArrayFrom( + innerEb + .selectFrom("ScrimPostRequestUser") + .innerJoin("User", "ScrimPostRequestUser.userId", "User.id") + .select((eb) => [ + ...commonUserSelect(eb), + "User.inGameName", + "ScrimPostRequestUser.isOwner", + ]) + .whereRef( + "ScrimPostRequestUser.scrimPostRequestId", + "=", + "ScrimPostRequest.id", + ), + ).as("users"), + ]) + .whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id"), + ).as("requests"), + ]); + +type BaseFindRow = Unwrapped; + +function findManyForRowType() { + return baseFindQuery.execute(); +} + +const mapDBRowToScrimPost = ( + row: BaseFindRow & { chatCode?: string }, +): ScrimPost => { + const someRequestIsAccepted = row.requests.some( + (request) => request.isAccepted, + ); + + // once one is accepted, rest are not relevant + const requests = someRequestIsAccepted + ? row.requests.filter((request) => request.isAccepted) + : row.requests; + + const users: ScrimPostUser[] = row.users.map((user) => ({ + ...user, + isOwner: Boolean(user.isOwner), + })); + + const ownerIds = users.filter((user) => user.isOwner).map((user) => user.id); + const managerIds = row.managedByAnyone + ? users.map((user) => user.id) + : ownerIds; + + let canceled: ScrimPost["canceled"] = null; + if (row.canceledAt && row.cancelReason) { + let cancelingUser = users.find((u) => u.id === row.canceledByUserId); + if (!cancelingUser) { + const allRequestUsers = requests.flatMap((request) => request.users); + const found = allRequestUsers.find((u) => u.id === row.canceledByUserId); + if (found) { + cancelingUser = { ...found, isOwner: Boolean(found.isOwner) }; + } + } + if (cancelingUser) { + canceled = { + at: row.canceledAt, + byUser: cancelingUser, + reason: row.cancelReason, + }; + } + } + + const result = { + id: row.id, + startsAt: row.startsAt, + rangeEndsAt: row.rangeEndsAt, + createdAt: row.createdAt, + visibility: row.visibility, + text: row.text, + isScheduledForFuture: Boolean(row.isScheduledForFuture), + divs: + typeof row.maxDiv === "number" && typeof row.minDiv === "number" + ? { + max: Scrim.parseLutiDiv(row.maxDiv), + min: Scrim.parseLutiDiv(row.minDiv), + } + : null, + maps: row.maps, + mapsTournament: row.mapsTournament.id + ? { + id: row.mapsTournament.id, + name: row.mapsTournament.name!, + avatarUrl: row.mapsTournament.avatarUrl, + } + : null, + chatCode: row.chatCode ?? null, + team: row.team.name + ? { + name: row.team.name, + customUrl: row.team.customUrl!, + avatarUrl: row.team.avatarUrl, + } + : null, + requests: requests.map((request) => { + return { + id: request.id, + isAccepted: Boolean(request.isAccepted), + createdAt: request.createdAt, + message: request.message, + startsAt: request.startsAt, + team: request.team.name + ? { + name: request.team.name, + customUrl: request.team.customUrl!, + avatarUrl: request.team.avatarUrl, + } + : null, + users: request.users.map((user) => ({ + ...user, + isOwner: Boolean(user.isOwner), + })), + permissions: { + CANCEL: request.users.map((u) => u.id), + }, + }; + }), + users, + permissions: { + MANAGE_REQUESTS: managerIds, + DELETE_POST: managerIds, + CANCEL: managerIds.concat(requests.at(0)?.users.map((u) => u.id) ?? []), + MANAGE_TRACKING: someRequestIsAccepted + ? users + .map((u) => u.id) + .concat(requests[0]?.users.map((u) => u.id) ?? []) + : [], + }, + managedByAnyone: Boolean(row.managedByAnyone), + canceled, + }; + + if (!Scrim.isAccepted(result)) { + return result; + } + + return { + ...result, + startsAt: Scrim.getStartTime(result), + rangeEndsAt: null, + }; +}; + +export type SidebarScrim = { + id: number; + startsAt: number; + opponentName: string | null; + opponentAvatarUrl: string | null; + /** Owner of an opponent without a team, whose avatar stands in for a team's logo. */ + opponentUser: CommonUser | null; + status: "booked" | "looking" | "requestPending"; +}; + +/** The user's upcoming scrims (and pending scrim requests) for the sidebar events list. */ +export async function findUserScrims(userId: number): Promise { + const now = dateToDatabaseTimestamp(new Date()); + + const rows = await baseFindQuery + .where("ScrimPost.canceledAt", "is", null) + .where("ScrimPost.startsAt", ">=", now) + .where((eb) => + eb.or([ + eb.exists( + eb + .selectFrom("ScrimPostUser") + .select("ScrimPostUser.scrimPostId") + .whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id") + .where("ScrimPostUser.userId", "=", userId), + ), + eb.exists( + eb + .selectFrom("ScrimPostRequest") + .innerJoin( + "ScrimPostRequestUser", + "ScrimPostRequestUser.scrimPostRequestId", + "ScrimPostRequest.id", + ) + .select("ScrimPostRequest.scrimPostId") + .whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id") + .where("ScrimPostRequestUser.userId", "=", userId), + ), + ]), + ) + .orderBy("ScrimPost.startsAt", "asc") + .execute(); + + return rows + .map(mapDBRowToScrimPost) + .filter( + (post) => !Scrim.isAccepted(post) || Scrim.isParticipating(post, userId), + ) + .map((post) => { + const isAccepted = Scrim.isAccepted(post); + const userIsInPost = post.users.some((u) => u.id === userId); + + if (!isAccepted) { + return { + id: post.id, + startsAt: post.startsAt, + opponentName: null, + opponentAvatarUrl: null, + opponentUser: null, + status: userIsInPost + ? ("looking" as const) + : ("requestPending" as const), + }; + } + + const opponent = userIsInPost + ? post.requests[0] + : { team: post.team, users: post.users }; + const opponentTeam = opponent?.team; + const opponentOwner = opponent?.users.find((u) => u.isOwner); + + return { + id: post.id, + startsAt: post.startsAt, + opponentName: opponentTeam?.name ?? opponentOwner?.username ?? null, + opponentAvatarUrl: opponentTeam?.avatarUrl ?? null, + opponentUser: opponentTeam ? null : (opponentOwner ?? null), + status: "booked" as const, + }; + }); +} diff --git a/apps/web/src/lib/features/scrims/scrims-types.ts b/apps/web/src/lib/features/scrims/scrims-types.ts new file mode 100644 index 000000000..ceed2c1b3 --- /dev/null +++ b/apps/web/src/lib/features/scrims/scrims-types.ts @@ -0,0 +1,84 @@ +import type { AssociationVisibility } from "#lib/features/associations/associations-types.ts"; +import type { CommonUser } from "#lib/server/kysely.ts"; + +export const LUTI_DIVS = [ + "X", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", +] as const; + +export type LutiDiv = (typeof LUTI_DIVS)[number]; + +export interface ScrimPost { + id: number; + startsAt: number; + rangeEndsAt: number | null; + createdAt: number; + visibility: AssociationVisibility | null; + text: string | null; + divs: { + /** Max div in the whole system is "X" */ + max: LutiDiv; + /** Min div in the whole system is "11" */ + min: LutiDiv; + } | null; + maps: "SZ" | "ALL" | "RANKED" | null; + mapsTournament: { + id: number; + name: string; + avatarUrl: string; + } | null; + team: ScrimPostTeam | null; + users: Array; + chatCode: string | null; + requests: Array; + /** Is the post visible to the user because of their association membership? */ + isPrivate?: boolean; + permissions: { + MANAGE_REQUESTS: number[]; + DELETE_POST: number[]; + CANCEL: number[]; + MANAGE_TRACKING: number[]; + }; + managedByAnyone: boolean; + /** When the post was made was it scheduled for a future time slot (as opposed to looking now) */ + isScheduledForFuture: boolean; + canceled: { + at: number; + byUser: ScrimPostUser; + reason: string; + } | null; +} + +export interface ScrimPostRequest { + id: number; + isAccepted: boolean; + users: Array; + team: ScrimPostTeam | null; + message: string | null; + startsAt: number | null; + permissions: { + CANCEL: number[]; + }; + createdAt: number; +} + +export interface ScrimPostUser extends CommonUser { + isOwner: boolean; + inGameName: string | null; +} + +interface ScrimPostTeam { + name: string; + customUrl: string; + avatarUrl: string | null; +} diff --git a/apps/web/src/lib/features/sendouq-streams/QStreamsRepository.server.ts b/apps/web/src/lib/features/sendouq-streams/QStreamsRepository.server.ts new file mode 100644 index 000000000..f202d2460 --- /dev/null +++ b/apps/web/src/lib/features/sendouq-streams/QStreamsRepository.server.ts @@ -0,0 +1,42 @@ +import type { Unwrapped } from "@sendou/utils/types"; +import { db } from "#lib/server/db/sql.ts"; +import { commonUserSelect, jsonObjectFrom } from "#lib/server/kysely.ts"; +import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts"; + +export type ActiveMatchPlayersItem = Unwrapped< + typeof findAllActiveMatchPlayers +>; + +/** Players of recently created SendouQ matches that are currently live on Twitch. */ +export function findAllActiveMatchPlayers() { + const oneHourAgo = new Date(Date.now() - 1000 * 60 * 60); + + return db + .selectFrom("Group") + .innerJoin("GroupMatch", (join) => + join.on((eb) => + eb.or([ + eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), + eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), + ]), + ), + ) + .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") + .select((eb) => [...commonUserSelect(eb), "User.twitch"]) + .whereRef("GroupMember.userId", "=", "User.id"), + ).as("user"), + ]) + .where("Group.status", "=", "ACTIVE") + .where("GroupMatch.createdAt", ">", dateToDatabaseTimestamp(oneHourAgo)) + .execute(); +} diff --git a/apps/web/src/lib/features/sendouq-streams/streams.server.ts b/apps/web/src/lib/features/sendouq-streams/streams.server.ts new file mode 100644 index 000000000..1156cf5ba --- /dev/null +++ b/apps/web/src/lib/features/sendouq-streams/streams.server.ts @@ -0,0 +1,158 @@ +import cachified from "@epic-web/cachified"; +import * as R from "remeda"; +import { + COMBINED_STREAMS_KEY, + type SidebarStream, +} from "#lib/features/core-streams/streams.server.ts"; +import { + cachedFullUserLeaderboard, + type UserLeaderboardWithAdditionsItem, +} from "#lib/features/leaderboards/leaderboards-core.server.ts"; +import * as Seasons from "#lib/features/mmr/Seasons.ts"; +import { TIERS, type TierName } from "#lib/features/mmr/mmr-constants.ts"; +import { cache, IN_MILLISECONDS, ttl } from "#lib/server/cache.ts"; +import { navIconUrl, SENDOUQ_STREAMS_PAGE, tierImageUrl } from "#lib/utils/urls.ts"; +import * as QStreamsRepository from "./QStreamsRepository.server.ts"; + +const SENDOUQ_STREAMS_KEY = "sendouq-streams"; + +/** + * Port note: unlike the React app the average tier of a streamed match is not + * resolved (it needs the SendouQ in-process group tiers, which migrate with + * sendouq). Until then streamed matches show the generic SendouQ icon. + */ + +export function cachedStreams() { + const season = Seasons.currentOrPrevious()!; + + return cachified({ + key: SENDOUQ_STREAMS_KEY, + cache: cache, + ttl: ttl(IN_MILLISECONDS.HALF_HOUR), + async getFreshValue() { + return streamedMatches({ + matchPlayers: await QStreamsRepository.findAllActiveMatchPlayers(), + leaderboard: await cachedFullUserLeaderboard(season.nth), + }).sort((a, b) => { + const aTierIndex = TIERS.findIndex( + (tier) => tier.name === a.tier?.name, + ); + const bTierIndex = TIERS.findIndex( + (tier) => tier.name === b.tier?.name, + ); + + // missing tiers sorted last + if (aTierIndex === -1 && bTierIndex !== -1) { + return 1; + } + if (aTierIndex !== -1 && bTierIndex === -1) { + return -1; + } + + // sort by base tier + if (aTierIndex !== bTierIndex) { + return aTierIndex - bTierIndex; + } + + // if base tier is the same, sort by plus + if (a.tier?.isPlus !== b.tier?.isPlus) { + return a.tier?.isPlus ? -1 : 1; + } + + // if tier is the same, sort by viewer count + return b.stream.viewerCount - a.stream.viewerCount; + }); + }, + }); +} + +function streamedMatches({ + matchPlayers, + leaderboard, +}: { + matchPlayers: QStreamsRepository.ActiveMatchPlayersItem[]; + leaderboard: UserLeaderboardWithAdditionsItem[]; +}) { + return matchPlayers.flatMap((player) => { + if (!player.streamTwitch) { + return []; + } + + const leaderboardEntry = leaderboard.find( + (entry) => entry.id === player.user?.id, + ); + + return { + stream: { + thumbnailUrl: player.streamThumbnailUrl, + twitchUserName: player.streamTwitch, + viewerCount: player.streamViewerCount, + }, + match: { + id: player.groupMatchId, + createdAt: player.groupMatchCreatedAt, + }, + user: { + ...player.user!, + twitch: player.user!.twitch!, + }, + weaponSplId: leaderboardEntry?.weaponSplId, + tier: leaderboardEntry?.tier, + }; + }); +} + +export type SendouQSidebarEntry = { + sidebarStream: SidebarStream; + tier: { name: TierName; isPlus: boolean } | null; + twitchUsernames: string[]; +}; + +/** Streamed SendouQ matches as sidebar stream entries. */ +export async function getSendouQSidebarStreams(): Promise< + SendouQSidebarEntry[] +> { + const streams = await cachedStreams(); + + const matchIdToStream = R.groupBy(streams, (s) => s.match.id); + + const entries: SendouQSidebarEntry[] = []; + + for (const [matchIdStr, matchStreams] of Object.entries(matchIdToStream)) { + const matchId = Number(matchIdStr); + const firstStream = matchStreams[0]; + + const averageTier: { name: TierName; isPlus: boolean } | null = null; + + const twitchUsernames = matchStreams + .map((s) => s.stream.twitchUserName) + .filter((t): t is string => t !== null); + + entries.push({ + sidebarStream: { + id: `sendouq-${matchId}`, + name: `Match #${matchId}`, + imageUrl: averageTier + ? `${tierImageUrl((averageTier as { name: TierName }).name)}.avif` + : `${navIconUrl("sendouq")}.avif`, + overlayIconUrl: averageTier + ? `${navIconUrl("sendouq")}.avif` + : undefined, + url: SENDOUQ_STREAMS_PAGE, + subtitle: "", + startsAt: firstStream.match.createdAt, + tier: null, + }, + tier: averageTier, + twitchUsernames, + }); + } + + return entries; +} + +/** SendouQ streams' Twitch usernames, for deduping against other stream sources. */ +export function refreshStreamsCache() { + cache.delete(SENDOUQ_STREAMS_KEY); + cache.delete(COMBINED_STREAMS_KEY); +} diff --git a/apps/web/src/lib/features/sendouq/q-groups.server.ts b/apps/web/src/lib/features/sendouq/q-groups.server.ts new file mode 100644 index 000000000..2abc3fec1 --- /dev/null +++ b/apps/web/src/lib/features/sendouq/q-groups.server.ts @@ -0,0 +1,87 @@ +import { db } from "#lib/server/db/sql.ts"; +import { databaseTimestampToDate } from "#lib/utils/dates.ts"; + +export const FULL_GROUP_SIZE = 4; + +export type GroupExpiryStatus = "EXPIRING_SOON" | "EXPIRED"; + +/** + * Lite port of the SendouQ in-process state for the app shell: only what the + * sidebar's friend activity needs (which group a user is in, its member count, + * match id and freshness). The full `SendouQ` class arrives with the sendouq + * migration; unlike the React singleton this queries fresh per call. + */ +export interface LiteGroup { + id: number; + status: "PREPARING" | "ACTIVE" | "INACTIVE" | "READY_CHECK"; + latestActionAt: number; + matchId: number | null; + memberUserIds: number[]; +} + +export async function findCurrentGroupsLite(): Promise { + const rows = await db + .selectFrom("Group") + .innerJoin("GroupMember", "GroupMember.groupId", "Group.id") + .leftJoin("GroupMatch", (join) => + join.on((eb) => + eb.or([ + eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")), + eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")), + ]), + ), + ) + .select([ + "Group.id", + "Group.status", + "Group.latestActionAt", + "GroupMatch.id as matchId", + "GroupMember.userId", + ]) + // != INACTIVE (same set as ACTIVE or PREPARING) so the partial + // group_status_active index applies + .where("Group.status", "!=", "INACTIVE") + .execute(); + + const groups = new Map(); + for (const row of rows) { + const group = groups.get(row.id) ?? { + id: row.id, + status: row.status, + latestActionAt: row.latestActionAt, + matchId: row.matchId, + memberUserIds: [], + }; + group.memberUserIds.push(row.userId); + groups.set(row.id, group); + } + + return [...groups.values()]; +} + +/** Finds the group a user belongs to, or `undefined` when they are not queued. */ +export function findOwnGroupLite(groups: LiteGroup[], userId: number) { + return groups.find((group) => group.memberUserIds.includes(userId)); +} + +export function groupExpiryStatus( + latestActionAt: number, +): GroupExpiryStatus | null { + // group expires in 30min without actions performed + const groupExpiresAt = + databaseTimestampToDate(latestActionAt).getTime() + 30 * 60 * 1000; + + const now = Date.now(); + + if (now > groupExpiresAt) { + return "EXPIRED"; + } + + const tenMinutesFromNow = now + 10 * 60 * 1000; + + if (tenMinutesFromNow > groupExpiresAt) { + return "EXPIRING_SOON"; + } + + return null; +} diff --git a/apps/web/src/lib/features/sidebar/StreamRanking.ts b/apps/web/src/lib/features/sidebar/StreamRanking.ts new file mode 100644 index 000000000..308028027 --- /dev/null +++ b/apps/web/src/lib/features/sidebar/StreamRanking.ts @@ -0,0 +1,88 @@ +import type { SidebarStream } from "#lib/features/core-streams/streams.server.ts"; +import { TIERS, type TierName } from "#lib/features/mmr/mmr-constants.ts"; +import type { TournamentTierNumber } from "#lib/features/tournament/tournament-types.ts"; + +type RankedStream = { stream: SidebarStream; score: number }; + +/** + * Score for admin-curated external streams. Below every other source's minimum (0) so they sort + * to the top of the ranking, reserving the first sidebar slots. + */ +export const EXTERNAL_STREAM_SCORE = -1; + +/** Picks the sidebar's streams: best scores first, live streams before upcoming ones. */ +export function rank( + streams: RankedStream[], + maxStreams: number, +): SidebarStream[] { + const now = Math.floor(Date.now() / 1000); + + const selected = streams + .sort((a, b) => a.score - b.score || a.stream.startsAt - b.stream.startsAt) + .slice(0, maxStreams); + + const live = selected + .filter((rs) => rs.stream.startsAt <= now) + .sort((a, b) => a.score - b.score); + + const upcoming = selected + .filter((rs) => rs.stream.startsAt > now) + .sort((a, b) => a.stream.startsAt - b.stream.startsAt); + + return [...live, ...upcoming].map((rs) => rs.stream); +} + +const SMALL_TOURNAMENT_PENALTY = 4; + +/** Score of a live tournament's streams based on its tier and team size. */ +export function tournamentTierToScore( + tier: TournamentTierNumber | null, + membersPerTeam?: number, +): number { + const base = tier ?? 9; + const isSmallTeamSize = (membersPerTeam ?? 4) < 4; + + return Math.min(9, base + (isSmallTeamSize ? SMALL_TOURNAMENT_PENALTY : 0)); +} + +/** Score of an upcoming tournament based on its (tentative) tier and team size. */ +export function upcomingTournamentTierToScore( + tier: number, + membersPerTeam?: number, +): number { + const isSmallTeamSize = (membersPerTeam ?? 4) < 4; + + return Math.min( + 9, + tier + 4 + (isSmallTeamSize ? SMALL_TOURNAMENT_PENALTY : 0), + ); +} + +/** Score of a streamed SendouQ match based on its average tier. */ +export function sendouQTierToScore(tier: { + name: TierName; + isPlus: boolean; +}): number { + const baseIndex = TIERS.findIndex((t) => t.name === tier.name); + if (baseIndex === -1) return 9; + return Math.min(9, baseIndex * 2 + (tier.isPlus ? 1 : 2)); +} + +const X_RANK_SCORES = [ + [3800, 5], + [3600, 6], + [3400, 7], + [3200, 8], + [3000, 9], +] as const; + +/** The lowest peak XP that qualifies a player's stream for the sidebar. */ +export function minXpForStreamToBeShown(): number { + return X_RANK_SCORES.at(-1)?.[0] ?? 3_000; +} + +/** Score of an X Rank player's stream based on their peak XP, or `null` when too low to show. */ +export function xpToScore(peakXp: number): number | null { + const entry = X_RANK_SCORES.find(([minXp]) => peakXp >= minXp); + return entry ? entry[1] : null; +} diff --git a/apps/web/src/lib/features/sidebar/sidebar.server.ts b/apps/web/src/lib/features/sidebar/sidebar.server.ts new file mode 100644 index 000000000..d06cfa0f7 --- /dev/null +++ b/apps/web/src/lib/features/sidebar/sidebar.server.ts @@ -0,0 +1,447 @@ +import { cachified } from "@epic-web/cachified"; +import { addDays } from "date-fns"; +import * as R from "remeda"; +import * as ExternalStreamRepository from "#lib/features/admin/ExternalStreamRepository.server.ts"; +import { userIsBanned } from "#lib/features/ban/banned.server.ts"; +import type { ShowcaseCalendarEvent } from "#lib/features/calendar/calendar-types.ts"; +import { + COMBINED_STREAMS_KEY, + getLiveTournamentStreamerTwitchNames, + getLiveTournamentStreams, + type SidebarStream, +} from "#lib/features/core-streams/streams.server.ts"; +import * as FriendRepository from "#lib/features/friends/FriendRepository.server.ts"; +import { + type FriendActivityType, + isInProgressFriendActivity, +} from "#lib/features/friends/friends-constants.ts"; +import { + type FriendActivity, + resolveFriendActivity, + resolveSendouQMatchStreams, +} from "#lib/features/friends/friends-utils.server.ts"; +import * as ShowcaseTournaments from "#lib/features/front-page/ShowcaseTournaments.server.ts"; +import * as LiveStreamRepository from "#lib/features/live-streams/LiveStreamRepository.server.ts"; +import type { SidebarScrim } from "#lib/features/scrims/ScrimPostRepository.server.ts"; +import * as ScrimPostRepository from "#lib/features/scrims/ScrimPostRepository.server.ts"; +import { + findCurrentGroupsLite, + type LiteGroup, +} from "#lib/features/sendouq/q-groups.server.ts"; +import { getSendouQSidebarStreams } from "#lib/features/sendouq-streams/streams.server.ts"; +import type { TournamentTierNumber } from "#lib/features/tournament/tournament-types.ts"; +import * as SavedCalendarEventRepository from "#lib/features/tournament/SavedCalendarEventRepository.server.ts"; +import { cache, ttl } from "#lib/server/cache.ts"; +import type { CommonUser } from "#lib/server/kysely.ts"; +import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts"; +import { + BLANK_IMAGE_URL, + discordAvatarUrl, + navIconUrl, + twitchUrl, + userPage, +} from "#lib/utils/urls.ts"; +import * as StreamRanking from "./StreamRanking.ts"; + +export type SidebarEvent = { + id: number; + name: string; + url: string; + logoUrl: string | null; + /** Whose avatar the event shows instead of a logo of its own. */ + user: CommonUser | null; + startsAt: number; + type: "tournament" | "scrim"; + scrimStatus?: "booked" | "looking" | "requestPending"; +}; + +export type SidebarFriend = { + id: number; + name: string; + discordId: string; + discordAvatar: string | null; + customAvatarUrl: string | null; + url: string; + subtitle: string; + badge: string; + activityType: FriendActivityType | null; + matchId: number | null; + tournamentId: number | null; + streamUrl: string | null; +}; + +const MAX_EVENTS_VISIBLE = 5; +const MAX_FRIENDS_VISIBLE = 8; +const MAX_STREAMS_VISIBLE = 5; +const UPCOMING_TOURNAMENT_WINDOW_DAYS = 3; +const SENDOUQ_QUOTA = 2; +const TOURNAMENT_SUB_QUOTA = 2; + +export async function resolveSidebarData(userId: number | null) { + if (!userId) { + return { + events: [] as SidebarEvent[], + friends: [] as SidebarFriend[], + streams: await combinedStreamsCached(), + savedTournamentIds: [] as number[], + incomingFriendRequestIds: [] as number[], + }; + } + + const [ + tournamentsData, + scrimsData, + friendsWithActivity, + savedTournaments, + incomingFriendRequestIds, + streamedSendouQMatches, + sendouQGroups, + ] = await Promise.all([ + ShowcaseTournaments.categorizedTournamentsByUserId(userId), + ScrimPostRepository.findUserScrims(userId), + FriendRepository.findByUserIdWithActivity(userId), + SavedCalendarEventRepository.findAllUpcomingByUserId(userId), + FriendRepository.findPendingReceivedRequestIds(userId), + resolveSendouQMatchStreams(), + findCurrentGroupsLite(), + ]); + + const seenTournamentIds = new Set(); + const tournamentEvents: SidebarEvent[] = [ + ...tournamentsData.participatingFor, + ...tournamentsData.organizingFor, + ] + .filter((t) => { + if (seenTournamentIds.has(t.id)) return false; + seenTournamentIds.add(t.id); + return true; + }) + .map(tournamentToSidebarEvent); + + const savedEvents: SidebarEvent[] = savedTournaments + .filter((t) => !seenTournamentIds.has(t.id)) + .map((t) => { + seenTournamentIds.add(t.id); + return tournamentToSidebarEvent(t); + }); + + const scrimEvents: SidebarEvent[] = scrimsData.map(scrimToSidebarEvent); + + const events = [...tournamentEvents, ...savedEvents, ...scrimEvents] + .sort((a, b) => a.startsAt - b.startsAt) + .slice(0, MAX_EVENTS_VISIBLE); + + const friends = resolveFriends( + friendsWithActivity, + streamedSendouQMatches, + sendouQGroups, + ); + + const savedTournamentIds = savedTournaments.map((t) => t.id); + + return { + events, + friends, + streams: await combinedStreamsCached(), + savedTournamentIds, + incomingFriendRequestIds, + }; +} + +function combinedStreamsCached(): Promise { + return cachified({ + key: COMBINED_STREAMS_KEY, + cache, + ttl: ttl(10 * 60 * 1000), + async getFreshValue() { + return combinedStreams(); + }, + }); +} + +async function combinedStreams(): Promise { + const tournamentStreams = getLiveTournamentStreams(); + const [sendouQEntries, xRankRows, upcomingTournaments, externalStreams] = + await Promise.all([ + getSendouQSidebarStreams(), + LiveStreamRepository.findXRankStreams(), + ShowcaseTournaments.upcomingTournaments(), + ExternalStreamRepository.findAllForSidebar(), + ]); + + const seenUsernames = new Set([ + ...getLiveTournamentStreamerTwitchNames(), + ...sendouQEntries.flatMap((e) => + e.twitchUsernames.map((t) => t.toLowerCase()), + ), + ]); + + const ranked: { stream: SidebarStream; score: number }[] = []; + + for (const externalStream of externalStreams) { + ranked.push({ + stream: { + id: `external-${externalStream.id}`, + name: externalStream.name, + imageUrl: externalStream.avatarUrl ?? BLANK_IMAGE_URL, + url: externalStream.url, + subtitle: "", + startsAt: externalStream.startsAt, + tier: null, + }, + score: StreamRanking.EXTERNAL_STREAM_SCORE, + }); + } + + for (const stream of tournamentStreams) { + ranked.push({ + stream, + score: StreamRanking.tournamentTierToScore( + stream.tier, + stream.membersPerTeam, + ), + }); + } + + for (const { sidebarStream, tier } of sendouQEntries) { + const score = tier ? StreamRanking.sendouQTierToScore(tier) : 9; + ranked.push({ stream: sidebarStream, score }); + } + + const xRankByUser = new Map(); + for (const row of xRankRows) { + const existing = xRankByUser.get(row.id); + if (!existing || (row.peakXp ?? 0) > (existing.peakXp ?? 0)) { + xRankByUser.set(row.id, row); + } + } + + for (const row of xRankByUser.values()) { + if (await userIsBanned(row.id)) continue; + + if ( + row.twitchUsername && + seenUsernames.has(row.twitchUsername.toLowerCase()) + ) { + continue; + } + + const score = StreamRanking.xpToScore(row.peakXp ?? 0); + if (score === null) continue; + + ranked.push({ + stream: { + id: `xrank-${row.id}`, + name: row.username, + imageUrl: row.customAvatarUrl + ? row.customAvatarUrl + : row.discordAvatar + ? discordAvatarUrl({ + discordId: row.discordId, + discordAvatar: row.discordAvatar, + size: "sm", + }) + : BLANK_IMAGE_URL, + url: row.twitchUsername + ? twitchUrl(row.twitchUsername) + : userPage({ discordId: row.discordId, customUrl: row.customUrl }), + subtitle: "", + startsAt: Math.floor(Date.now() / 1000), + tier: null, + peakXp: row.peakXp ?? undefined, + }, + score, + }); + } + + const nowTimestamp = dateToDatabaseTimestamp(new Date()); + const threeDaysFromNow = dateToDatabaseTimestamp( + addDays(new Date(), UPCOMING_TOURNAMENT_WINDOW_DAYS), + ); + for (const event of upcomingTournaments) { + const effectiveTier = event.tier ?? event.tentativeTier; + if (effectiveTier === null) continue; + if (event.startsAt < nowTimestamp) continue; + if (event.startsAt > threeDaysFromNow) continue; + if (event.hidden) continue; + + const membersPerTeam = event.minMembersPerTeam ?? 4; + + ranked.push({ + stream: { + id: `upcoming-${event.id}`, + name: event.name, + imageUrl: event.logoUrl ?? BLANK_IMAGE_URL, + url: event.url, + subtitle: "", + startsAt: event.startsAt, + tier: (event.tier as TournamentTierNumber) ?? null, + membersPerTeam, + tentativeTier: event.tentativeTier ?? undefined, + }, + score: StreamRanking.upcomingTournamentTierToScore( + effectiveTier, + membersPerTeam, + ), + }); + } + + return StreamRanking.rank(ranked, MAX_STREAMS_VISIBLE); +} + +type FriendWithActivity = Awaited< + ReturnType +>[number]; + +function resolveFriends( + friendsWithActivity: FriendWithActivity[], + streamedSendouQMatches: ReadonlyMap, + sendouQGroups: LiteGroup[], +) { + const activityForRow = (row: FriendWithActivity) => + resolveFriendActivity({ + friendId: row.id, + tournamentId: row.tournamentId, + tournamentName: row.tournamentName, + teamMemberCount: row.teamMemberCount, + tournamentMinTeamSize: row.tournamentMinTeamSize, + sendouQMatchStreams: streamedSendouQMatches, + sendouQGroups, + }); + + const unique = R.uniqueBy(friendsWithActivity, (f) => f.id); + const friendRows = unique.filter((f) => f.friendshipId !== null); + const teamMemberRows = unique.filter((f) => f.friendshipId === null); + + const activeFriends: SidebarFriend[] = []; + const sendouqFriends: SidebarFriend[] = []; + const tournamentSubFriends: SidebarFriend[] = []; + const inactiveFriends: FriendWithActivity[] = []; + + for (const friend of friendRows) { + const activity = activityForRow(friend); + + if (!activity.type) { + inactiveFriends.push(friend); + continue; + } + + const sidebarFriend = rowToSidebarFriend(friend, activity); + + if (isInProgressFriendActivity(activity.type)) { + activeFriends.push(sidebarFriend); + } else if (activity.type === "SENDOUQ") { + sendouqFriends.push(sidebarFriend); + } else { + tournamentSubFriends.push(sidebarFriend); + } + } + + const result: SidebarFriend[] = []; + + const sendouqToShow = sendouqFriends.slice(0, SENDOUQ_QUOTA); + const tournamentToShow = tournamentSubFriends.slice(0, TOURNAMENT_SUB_QUOTA); + + result.push(...sendouqToShow, ...tournamentToShow); + + const remaining = MAX_FRIENDS_VISIBLE - result.length; + if (remaining > 0) { + const extraSendouq = sendouqFriends.slice(SENDOUQ_QUOTA); + const extraTournament = tournamentSubFriends.slice(TOURNAMENT_SUB_QUOTA); + result.push(...[...extraSendouq, ...extraTournament].slice(0, remaining)); + } + + if (result.length < MAX_FRIENDS_VISIBLE) { + result.push(...activeFriends.slice(0, MAX_FRIENDS_VISIBLE - result.length)); + } + + if (result.length < MAX_FRIENDS_VISIBLE) { + const shownIds = new Set(result.map((f) => f.id)); + const inactiveTeamMembers: FriendWithActivity[] = []; + + for (const tm of teamMemberRows) { + if (result.length >= MAX_FRIENDS_VISIBLE) break; + if (shownIds.has(tm.id)) continue; + + const activity = activityForRow(tm); + if (!activity.type) { + inactiveTeamMembers.push(tm); + continue; + } + + result.push(rowToSidebarFriend(tm, activity)); + shownIds.add(tm.id); + } + + for (const friend of inactiveFriends) { + if (result.length >= MAX_FRIENDS_VISIBLE) break; + if (shownIds.has(friend.id)) continue; + + result.push(rowToSidebarFriend(friend, null)); + shownIds.add(friend.id); + } + + for (const tm of inactiveTeamMembers) { + if (result.length >= MAX_FRIENDS_VISIBLE) break; + + result.push(rowToSidebarFriend(tm, null)); + } + } + + return result.slice(0, MAX_FRIENDS_VISIBLE); +} + +function rowToSidebarFriend( + row: FriendWithActivity, + activity: FriendActivity | null, +): SidebarFriend { + return { + id: row.id, + name: row.username, + discordId: row.discordId, + discordAvatar: row.discordAvatar, + customAvatarUrl: row.customAvatarUrl, + url: userPage({ discordId: row.discordId, customUrl: row.customUrl }), + subtitle: activity?.subtitle ?? "", + badge: activity?.badge ?? "", + activityType: activity?.type ?? null, + matchId: activity?.matchId ?? null, + tournamentId: activity?.tournamentId ?? row.tournamentId, + streamUrl: activity?.streamUrl ?? null, + }; +} + +export function tournamentToSidebarEvent( + t: ShowcaseCalendarEvent, +): SidebarEvent { + return { + id: t.id, + name: t.name, + url: t.url, + logoUrl: t.logoUrl, + user: null, + startsAt: t.startsAt, + type: "tournament" as const, + }; +} + +const SCRIMS_ICON_URL = `${navIconUrl("scrims")}.avif`; + +export function scrimToSidebarEvent(s: SidebarScrim): SidebarEvent { + return { + id: s.id, + name: s.opponentName ?? "Scrim", + url: + s.status === "booked" + ? `/scrims/${s.id}` + : s.status === "requestPending" + ? `/scrims?pendingRequestPostId=${s.id}` + : "/scrims", + // an opponent without a team is shown by their owner's avatar instead + logoUrl: s.opponentAvatarUrl ?? (s.opponentUser ? null : SCRIMS_ICON_URL), + user: s.opponentUser, + startsAt: s.startsAt, + type: "scrim" as const, + scrimStatus: s.status, + }; +} diff --git a/apps/web/src/lib/features/theme/ThemeHead.svelte b/apps/web/src/lib/features/theme/ThemeHead.svelte new file mode 100644 index 000000000..b942432db --- /dev/null +++ b/apps/web/src/lib/features/theme/ThemeHead.svelte @@ -0,0 +1,41 @@ + + + + + {#if theme === null} + + {@html ``} + {/if} + diff --git a/apps/web/src/lib/features/tournament-organization/tentativeTiers.server.ts b/apps/web/src/lib/features/tournament-organization/tentativeTiers.server.ts new file mode 100644 index 000000000..1c27d8ba8 --- /dev/null +++ b/apps/web/src/lib/features/tournament-organization/tentativeTiers.server.ts @@ -0,0 +1,60 @@ +import { calculateTentativeTier } from "#lib/features/tournament/tournament-utils.ts"; +import { db } from "#lib/server/db/sql.ts"; + +interface SeriesMatch { + substringMatches: string[]; + tentativeTier: number; +} + +function findAllSeriesWithTierHistory() { + return db + .selectFrom("TournamentOrganizationSeries") + .select(["organizationId", "substringMatches", "tierHistory"]) + .execute(); +} + +let cache: Map | null = null; + +async function loadCache(): Promise> { + const rows = await findAllSeriesWithTierHistory(); + + const result = new Map(); + for (const row of rows) { + if (!row.tierHistory?.length) continue; + + const tentativeTier = calculateTentativeTier(row.tierHistory); + if (tentativeTier === null) continue; + + const existing = result.get(row.organizationId) ?? []; + existing.push({ + substringMatches: row.substringMatches, + tentativeTier, + }); + result.set(row.organizationId, existing); + } + return result; +} + +/** Tentative tier of a tournament based on its organization's series history, or `null` when it has none. */ +export async function getTentativeTier( + orgId: number, + tournamentName: string, +): Promise { + if (!cache) { + cache = await loadCache(); + } + + const seriesList = cache.get(orgId); + if (!seriesList) return null; + + const nameLower = tournamentName.toLowerCase(); + const match = seriesList.find((s) => + s.substringMatches.some((m) => nameLower.includes(m.toLowerCase())), + ); + + return match?.tentativeTier ?? null; +} + +export async function refreshTentativeTiersCache(): Promise { + cache = await loadCache(); +} diff --git a/apps/web/src/lib/features/tournament/SavedCalendarEventRepository.server.ts b/apps/web/src/lib/features/tournament/SavedCalendarEventRepository.server.ts new file mode 100644 index 000000000..e2eb583ad --- /dev/null +++ b/apps/web/src/lib/features/tournament/SavedCalendarEventRepository.server.ts @@ -0,0 +1,44 @@ +import { db } from "#lib/server/db/sql.ts"; +import type { ShowcaseCalendarEvent } from "#lib/features/calendar/calendar-types.ts"; +import * as ShowcaseTournaments from "#lib/features/front-page/ShowcaseTournaments.server.ts"; + +async function findCalendarEventIdsByUserId(userId: number): Promise { + const rows = await db + .selectFrom("SavedCalendarEvent") + .select("calendarEventId") + .where("userId", "=", userId) + .execute(); + + return rows.map((r) => r.calendarEventId); +} + +/** Upcoming tournaments the user has saved to their calendar. */ +export async function findAllUpcomingByUserId( + userId: number, +): Promise { + const [savedCalendarEventIds, tournaments] = await Promise.all([ + findCalendarEventIdsByUserId(userId), + ShowcaseTournaments.upcomingTournaments(), + ]); + + const savedTournamentIds = await tournamentIdsByCalendarEventIds( + savedCalendarEventIds, + ); + + return tournaments.filter((t) => savedTournamentIds.includes(t.id)); +} + +async function tournamentIdsByCalendarEventIds( + calendarEventIds: number[], +): Promise { + if (calendarEventIds.length === 0) return []; + + const rows = await db + .selectFrom("CalendarEvent") + .select("CalendarEvent.tournamentId") + .where("CalendarEvent.id", "in", calendarEventIds) + .where("CalendarEvent.tournamentId", "is not", null) + .execute(); + + return rows.map((r) => r.tournamentId!); +} diff --git a/apps/web/src/lib/features/tournament/TournamentRepository.server.ts b/apps/web/src/lib/features/tournament/TournamentRepository.server.ts new file mode 100644 index 000000000..41591a6d4 --- /dev/null +++ b/apps/web/src/lib/features/tournament/TournamentRepository.server.ts @@ -0,0 +1,152 @@ +import type { NotNull } from "kysely"; +import type { Unwrapped } from "@sendou/utils/types"; +import { db } from "#lib/server/db/sql.ts"; +import { + commonUserSelect, + concatUserSubmittedImagePrefix, + jsonArrayFrom, + jsonObjectFrom, + tournamentLogoWithDefault, + tournamentMembersCount, + tournamentTeamsCount, +} from "#lib/server/kysely.ts"; +import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts"; + +export type ForShowcase = Unwrapped; + +/** Tournaments of the last week and upcoming ones, with the aggregates the showcase/sidebar need. */ +export function findAllForShowcase() { + return db + .selectFrom("Tournament") + .innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId") + .innerJoin( + "CalendarEventDate", + "CalendarEvent.id", + "CalendarEventDate.eventId", + ) + .select((eb) => [ + "Tournament.id", + "Tournament.settings", + "Tournament.tier", + "Tournament.isFinalized", + "CalendarEvent.authorId", + "CalendarEvent.name", + "CalendarEvent.organizationId", + "CalendarEventDate.startsAt", + "CalendarEvent.hidden", + tournamentTeamsCount(eb).as("teamsCount"), + tournamentMembersCount(eb).as("membersCount"), + tournamentLogoWithDefault(eb).as("logoUrl"), + jsonObjectFrom( + eb + .selectFrom("TournamentOrganization") + .select([ + "TournamentOrganization.name", + "TournamentOrganization.slug", + ]) + .whereRef( + "TournamentOrganization.id", + "=", + "CalendarEvent.organizationId", + ), + ).as("organization"), + jsonArrayFrom( + eb + .selectFrom("TournamentResult") + .innerJoin("User", "TournamentResult.userId", "User.id") + .innerJoin( + "TournamentTeam", + "TournamentResult.tournamentTeamId", + "TournamentTeam.id", + ) + .leftJoin("AllTeam", "TournamentTeam.teamId", "AllTeam.id") + .leftJoin( + "UserSubmittedImage as TeamAvatar", + "AllTeam.avatarImgId", + "TeamAvatar.id", + ) + .leftJoin( + "UserSubmittedImage as TournamentTeamAvatar", + "TournamentTeam.avatarImgId", + "TournamentTeamAvatar.id", + ) + .whereRef("TournamentResult.tournamentId", "=", "Tournament.id") + .where("TournamentResult.placement", "=", 1) + .select((eb) => [ + ...commonUserSelect(eb, { inTournament: true }), + "User.country", + "TournamentResult.div", + "TournamentTeam.name as teamName", + concatUserSubmittedImagePrefix(eb.ref("TeamAvatar.url")).as( + "teamLogoUrl", + ), + concatUserSubmittedImagePrefix( + eb.ref("TournamentTeamAvatar.url"), + ).as("pickupAvatarUrl"), + ]), + ).as("firstPlacers"), + eb + .selectFrom("TournamentMatchVod") + .innerJoin( + "TournamentMatch", + "TournamentMatch.id", + "TournamentMatchVod.matchId", + ) + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .whereRef("TournamentStage.tournamentId", "=", "Tournament.id") + .select(({ fn }) => [fn.countAll().as("count")]) + .as("vodCount"), + ]) + .where("CalendarEventDate.startsAt", ">", databaseTimestampWeekAgo()) + .orderBy("CalendarEventDate.startsAt", "asc") + .$narrowType<{ teamsCount: NotNull; membersCount: NotNull }>() + .execute(); +} + +function databaseTimestampWeekAgo() { + const now = new Date(); + + now.setDate(now.getDate() - 7); + + return dateToDatabaseTimestamp(now); +} + +/** Team members, staff and author of the given tournaments, for participation categorization. */ +export function findRelatedUsersByTournamentIds(tournamentIds: number[]) { + return db + .selectFrom("CalendarEventDate") + .innerJoin("CalendarEvent", "CalendarEventDate.eventId", "CalendarEvent.id") + .innerJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id") + .select((eb) => [ + "Tournament.id", + "CalendarEvent.authorId", + jsonArrayFrom( + eb + .selectFrom("TournamentStaff") + .select(["TournamentStaff.userId"]) + .whereRef("TournamentStaff.tournamentId", "=", "Tournament.id") + .where("TournamentStaff.role", "=", "ORGANIZER"), + ).as("staff"), + jsonArrayFrom( + eb + .selectFrom("TournamentTeam") + .innerJoin( + "TournamentTeamMember", + "TournamentTeamMember.tournamentTeamId", + "TournamentTeam.id", + ) + .select(["TournamentTeamMember.userId"]) + .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id"), + ).as("teamMembers"), + ]) + .where("Tournament.id", "in", tournamentIds) + .$narrowType<{ + staff: NotNull; + teamMembers: NotNull; + }>() + .execute(); +} diff --git a/apps/web/src/lib/features/tournament/tournament-types.ts b/apps/web/src/lib/features/tournament/tournament-types.ts new file mode 100644 index 000000000..ac8e1203b --- /dev/null +++ b/apps/web/src/lib/features/tournament/tournament-types.ts @@ -0,0 +1,28 @@ +export const TOURNAMENT_STAFF_ROLES = ["ORGANIZER", "STREAMER"] as const; +export type TournamentStaffRole = (typeof TOURNAMENT_STAFF_ROLES)[number]; + +const TIER_TO_NUMBER = { + X: 1, + "S+": 2, + S: 3, + "A+": 4, + A: 5, + "B+": 6, + B: 7, + "C+": 8, + C: 9, +} as const; + +export type TournamentTier = keyof typeof TIER_TO_NUMBER; +export type TournamentTierNumber = (typeof TIER_TO_NUMBER)[TournamentTier]; + +/** + * The subset of the React app's `TournamentSettings` JSON payload that migrated + * features read. Columns not listed here are simply not typed yet. + */ +export interface TournamentSettingsLite { + isRanked?: boolean; + isTest?: boolean; + minMembersPerTeam?: number; + regClosesAt?: number; +} diff --git a/apps/web/src/lib/features/tournament/tournament-utils.ts b/apps/web/src/lib/features/tournament/tournament-utils.ts new file mode 100644 index 000000000..918ea05d3 --- /dev/null +++ b/apps/web/src/lib/features/tournament/tournament-utils.ts @@ -0,0 +1,42 @@ +import * as Seasons from "#lib/features/mmr/Seasons.ts"; +import type { TournamentTierNumber } from "./tournament-types.ts"; + +/** Whether a tournament counts for ranked SP, resolved the same way the tournament page does. */ +export function tournamentIsRanked({ + isSetAsRanked, + startsAt, + minMembersPerTeam, + isTest, +}: { + isSetAsRanked?: boolean; + startsAt: Date; + minMembersPerTeam: number; + isTest: boolean; +}) { + if (isTest) return false; + + const seasonIsActive = Boolean(Seasons.current(startsAt)); + if (!seasonIsActive) return false; + + // 1v1, 2v2 and 3v3 are always considered "gimmicky" + if (minMembersPerTeam !== 4) return false; + + return isSetAsRanked ?? true; +} + +/** Tentative tier of a series: the (upper) median of its recorded tier history. */ +export function calculateTentativeTier( + tierHistory: TournamentTierNumber[], +): TournamentTierNumber | null { + if (tierHistory.length === 0) return null; + + const sorted = [...tierHistory].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 0) { + return Math.ceil( + (sorted[mid - 1] + sorted[mid]) / 2, + ) as TournamentTierNumber; + } + return sorted[mid]; +} diff --git a/apps/web/src/lib/features/trophies/trophies-utils.ts b/apps/web/src/lib/features/trophies/trophies-utils.ts new file mode 100644 index 000000000..376997f26 --- /dev/null +++ b/apps/web/src/lib/features/trophies/trophies-utils.ts @@ -0,0 +1,11 @@ +import type { Role } from "#lib/modules/permissions/types.ts"; + +const TROPHIES_RELEASED = false; + +/** Whether the user can access the trophies pages while the feature is unreleased. */ +export function canAccessTrophies(user?: { roles: Array } | null) { + if (TROPHIES_RELEASED) return true; + if (!user) return false; + + return user.roles.includes("ADMIN") || user.roles.includes("QA"); +} diff --git a/apps/web/src/lib/features/user-page/UserRepository.server.ts b/apps/web/src/lib/features/user-page/UserRepository.server.ts index 52a2de01a..ae43aa7c1 100644 --- a/apps/web/src/lib/features/user-page/UserRepository.server.ts +++ b/apps/web/src/lib/features/user-page/UserRepository.server.ts @@ -48,6 +48,17 @@ export async function findLeanById(id: number) { }; } +/** Every current patron, highest tiers first, longest-running patrons first within a tier. */ +export function findAllPatrons() { + return db + .selectFrom("User") + .select(["User.id", "User.discordId", "User.username", "User.patronTier"]) + .where("User.patronTier", "is not", null) + .orderBy("User.patronTier", "desc") + .orderBy("User.patronStartedAt", "asc") + .execute(); +} + /** Every user in the plus server with their tier. */ export function findAllPlusServerMembers() { return db diff --git a/apps/web/src/lib/server/db/tables.ts b/apps/web/src/lib/server/db/tables.ts index fc9cbdb8a..4e5b734bd 100644 --- a/apps/web/src/lib/server/db/tables.ts +++ b/apps/web/src/lib/server/db/tables.ts @@ -6,8 +6,16 @@ import type { ColumnType, GeneratedAlways, Insertable, + JSONColumnType, Selectable, } from "kysely"; +import type { AssociationVisibility } from "#lib/features/associations/associations-types.ts"; +import type { Notification as NotificationValue } from "#lib/features/notifications/notifications-types.ts"; +import type { + TournamentSettingsLite, + TournamentStaffRole, + TournamentTierNumber, +} from "#lib/features/tournament/tournament-types.ts"; import type { PeakXP, UserPreferences, @@ -61,8 +69,10 @@ export interface User { isApiAccesser: Generated; languages: JSONColumnTypeNullable; patronTier: number | null; + patronStartedAt: number | null; preferences: JSONColumnTypeNullable; plusSkippedForSeasonNth: number | null; + twitch: string | null; /** User creation date. Can be null because we did not always save this. */ createdAt: number | null; } @@ -214,9 +224,246 @@ export interface GroupMember { } export interface Tournament { + /** Trimmed to the fields the migrated features read; the React app's `TournamentSettings` stays the source of truth. */ + settings: JSONColumnType; id: GeneratedAlways; /** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */ isFinalized: Generated; + /** Tournament tier based on top teams' skill. 1=X, 2=S+, 3=S, 4=A+, 5=A, 6=B+, 7=B, 8=C+, 9=C */ + tier: TournamentTierNumber | null; +} + +export interface CalendarEvent { + authorId: number; + bracketUrl: string; + description: string | null; + discordInviteCode: string | null; + id: GeneratedAlways; + discordUrl: GeneratedAlways; + name: string; + participantCount: number | null; + hidden: Generated; + tournamentId: number | null; + organizationId: number | null; + avatarImgId: number | null; + trophyId: number | null; +} + +export interface CalendarEventDate { + eventId: number; + id: GeneratedAlways; + startsAt: number; +} + +export interface SavedCalendarEvent { + id: GeneratedAlways; + userId: number; + calendarEventId: number; + createdAt: Generated; +} + +export interface TournamentStaff { + tournamentId: number; + userId: number; + role: TournamentStaffRole; +} + +export interface TournamentTeam { + createdAt: Generated; + id: GeneratedAlways; + inviteCode: string; + name: string; + prefersNotToHost: Generated; + droppedOut: Generated; + seed: number | null; + /** For formats that have many starting brackets, where should the team start? */ + startingBracketIdx: number | null; + activeRosterUserIds: JSONColumnTypeNullable; + tournamentId: number; + teamId: number | null; + avatarImgId: number | null; + isLooking: Generated; + isPlaceholder: Generated; + lfgNote: string | null; + chatCode: Generated; + /** A/B division assignment for bipartite round robin brackets. `0` = A, `1` = B, `null` = unassigned. */ + abDivision: number | null; + /** The team's `TournamentTeamHistory` row, created lazily on its first audited event. */ + tournamentTeamHistoryId: number | null; +} + +export interface TournamentTeamCheckIn { + checkedInAt: number; + /** Which bracket checked in for. If missing is check in for the whole event. */ + bracketIdx: number | null; + tournamentTeamId: number; + /** Indicates that this bracket defaults to checked in and this team has been explicitly checked out from it */ + isCheckOut: Generated; +} + +export interface TournamentTeamMember { + createdAt: Generated; + inGameName: string | null; + tournamentTeamId: number; + userId: number; + role: Generated<"OWNER" | "MANAGER" | "REGULAR">; + isStayAsSub: Generated; + /** Set when the member was added to the roster after registration closed. */ + isSub: Generated; + /** Set when the member was added to the roster by the tournament organizer instead of joining on their own. */ + isOrganizerAdded: Generated; + // denormalized from TournamentTeam.isLooking + isLooking: Generated; +} + +export interface TournamentOrganization { + id: GeneratedAlways; + name: string; + slug: string; + description: string | null; + socials: JSONColumnTypeNullable; + avatarImgId: number | null; + isEstablished: Generated; +} + +export interface TournamentOrganizationSeries { + id: GeneratedAlways; + organizationId: number; + name: string; + description: string | null; + substringMatches: JSONColumnType; + showLeaderboard: Generated; + tierHistory: JSONColumnTypeNullable; +} + +export interface TournamentMatchVod { + id: GeneratedAlways; + matchId: number; + userId: number | null; + platform: "TWITCH"; + account: string; + platformVideoId: string; + timestampSeconds: number; + viewCount: number; +} + +export interface Friendship { + id: GeneratedAlways; + userOneId: number; + userTwoId: number; + createdAt: Generated; +} + +/** Pending friend request from one user to another. */ +export interface FriendRequest { + id: GeneratedAlways; + senderId: number; + receiverId: number; + createdAt: Generated; +} + +export interface Group { + chatCode: string | null; + createdAt: Generated; + id: GeneratedAlways; + inviteCode: string; + latestActionAt: Generated; + /** If truthy, group was at least partly made in the matchmaking UI (/q/looking) */ + matchmade: Generated; + status: "PREPARING" | "ACTIVE" | "INACTIVE" | "READY_CHECK"; + teamId: number | null; +} + +export interface LiveStream { + id: GeneratedAlways; + userId: number | null; + viewerCount: number; + thumbnailUrl: string; + twitch: string | null; +} + +export interface ExternalStream { + id: GeneratedAlways; + name: string; + url: string; + avatarImgId: number | null; + startsAt: number; + createdAt: Generated; +} + +export interface Notification { + id: GeneratedAlways; + type: NotificationValue["type"]; + meta: JSONColumnTypeNullable>; + pictureUrl: string | null; + createdAt: Generated; +} + +export interface NotificationUser { + notificationId: number; + userId: number; + seen: Generated; +} + +export interface ScrimPost { + id: GeneratedAlways; + /** When is the scrim scheduled to happen */ + startsAt: number; + /** Optional end of time range indicating team accepts scrims starting between startsAt and rangeEndsAt */ + rangeEndsAt: number | null; + /** Highest LUTI div accepted */ + maxDiv: number | null; + /** Lowest LUTI div accepted */ + minDiv: number | null; + /** Who sees the post */ + visibility: JSONColumnTypeNullable; + /** Any additional info */ + text: string | null; + /** The key to access the scrim chat, used after scrim is scheduled with another team */ + chatCode: string; + /** Refers to the team looking for the team (can also be a pick-up) */ + teamId: number | null; + /** Indicates if anyone in the post can manage it */ + managedByAnyone: DBBoolean; + /** When the scrim was canceled */ + canceledAt: number | null; + /** User id who canceled the scrim */ + canceledByUserId: number | null; + /** Reason for canceling the scrim */ + cancelReason: string | null; + /** When the post was made was it scheduled for a future time slot (as opposed to looking now) */ + isScheduledForFuture: Generated; + /** Maps/modes the scrim is available for. If null means no preference unless "mapsTournamentId" is set */ + maps: "SZ" | "ALL" | "RANKED" | null; + /** If set, specifies the maps of a tournament to play */ + mapsTournamentId: number | null; + createdAt: Generated; + updatedAt: Generated; +} + +export interface ScrimPostUser { + scrimPostId: number; + userId: number; + /** User is the author of the post */ + isOwner: DBBoolean; +} + +export interface ScrimPostRequest { + id: GeneratedAlways; + scrimPostId: number; + teamId: number | null; + message: string | null; + /** Specific time selected by requester (required when post has rangeEndsAt) */ + startsAt: number | null; + isAccepted: Generated; + createdAt: Generated; +} + +export interface ScrimPostRequestUser { + scrimPostRequestId: number; + /** User that made the request */ + userId: number; + isOwner: DBBoolean; } export interface TournamentStage { @@ -258,11 +505,25 @@ export interface DB { AllTeam: Team; /** Table backing the `TeamMember` & `TeamMemberWithSecondary` views. Includes members who have left and members of deleted teams. */ AllTeamMember: TeamMember; + CalendarEvent: CalendarEvent; + CalendarEventDate: CalendarEventDate; + ExternalStream: ExternalStream; + FriendRequest: FriendRequest; + Friendship: Friendship; + Group: Group; GroupMatch: GroupMatch; GroupMember: GroupMember; LeaderboardTeamSkip: LeaderboardTeamSkip; + LiveStream: LiveStream; + Notification: Notification; + NotificationUser: NotificationUser; PlusTier: PlusTier; ReportedWeapon: ReportedWeapon; + SavedCalendarEvent: SavedCalendarEvent; + ScrimPost: ScrimPost; + ScrimPostRequest: ScrimPostRequest; + ScrimPostRequestUser: ScrimPostRequestUser; + ScrimPostUser: ScrimPostUser; Skill: Skill; SkillTeamUser: SkillTeamUser; SplatoonPlayer: SplatoonPlayer; @@ -274,8 +535,15 @@ export interface DB { TeamMemberWithSecondary: TeamMember; Tournament: Tournament; TournamentMatch: TournamentMatch; + TournamentMatchVod: TournamentMatchVod; + TournamentOrganization: TournamentOrganization; + TournamentOrganizationSeries: TournamentOrganizationSeries; TournamentResult: TournamentResult; + TournamentStaff: TournamentStaff; TournamentStage: TournamentStage; + TournamentTeam: TournamentTeam; + TournamentTeamCheckIn: TournamentTeamCheckIn; + TournamentTeamMember: TournamentTeamMember; UnvalidatedUserSubmittedImage: UnvalidatedUserSubmittedImage; User: User; UserFriendCode: UserFriendCode; diff --git a/apps/web/src/lib/server/kysely.ts b/apps/web/src/lib/server/kysely.ts index e6ec1ffdc..3864f1651 100644 --- a/apps/web/src/lib/server/kysely.ts +++ b/apps/web/src/lib/server/kysely.ts @@ -117,6 +117,116 @@ export type UserWithPlusTier = Tables["User"] & { plusTier: Tables["PlusTier"]["tier"] | null; }; +/** SQL expression resolving to a SplatoonPlayer's overall peak XP. `"SplatoonPlayer"` must be in scope at the call site. */ +export function peakXpOverallSql() { + return sql`"SplatoonPlayer"."peakXp" ->> '$.overall'`; +} + +/** + * Constructs a SQL expression that returns the full URL for a tournament's logo. + * If the tournament has a custom logo (via avatarImgId), returns that logo's URL. + * Otherwise, falls back to the default tournament logo. + */ +export function tournamentLogoWithDefault( + eb: ExpressionBuilder, +) { + return concatUserSubmittedImagePrefix( + eb.fn.coalesce( + eb + .selectFrom("UnvalidatedUserSubmittedImage") + .select("UnvalidatedUserSubmittedImage.url") + .whereRef( + "CalendarEvent.avatarImgId", + "=", + "UnvalidatedUserSubmittedImage.id", + ) + .$asScalar(), + sql.lit(Config.tournamentDefaultLogo), + ), + ); +} + +/** Expression resolving to whether any of a tournament's brackets has been started. */ +function tournamentHasStarted(eb: ExpressionBuilder) { + return eb.exists( + eb + .selectFrom("TournamentStage") + .select("TournamentStage.id") + .whereRef("TournamentStage.tournamentId", "=", "Tournament.id"), + ); +} + +/** + * Subquery resolving to the non-placeholder teams of a tournament that are still relevant to it: + * every registered team as long as no bracket has been started, only the checked in ones after + * that. Mirrors how the tournament page itself resolves its teams, so keep the two in sync. + * Correlates on `"Tournament"."id"`. + */ +function tournamentCheckedInTeams(eb: ExpressionBuilder) { + return eb + .selectFrom("TournamentTeam") + .leftJoin( + "TournamentTeamCheckIn", + "TournamentTeamCheckIn.tournamentTeamId", + "TournamentTeam.id", + ) + .whereRef("TournamentTeam.tournamentId", "=", "Tournament.id") + .where("TournamentTeam.isPlaceholder", "=", 0) + .where((eb2) => + eb2.or([ + eb2("TournamentTeamCheckIn.checkedInAt", "is not", null), + eb2.not(tournamentHasStarted(eb)), + ]), + ); +} + +/** + * Subquery counting the teams of {@link tournamentCheckedInTeams}. Correlates on + * `"Tournament"."id"`. Alias it `.as("teamsCount")` when selecting it directly. + */ +export function tournamentTeamsCount(eb: ExpressionBuilder) { + return tournamentCheckedInTeams(eb).select(({ fn }) => [ + fn.count("TournamentTeam.id").distinct().as("count"), + ]); +} + +/** + * Expression resolving to a tournament's participant count: rostered players of the teams from + * {@link tournamentCheckedInTeams} while the tournament is still ongoing, players who actually got + * a result once it has been finalized. Correlates on `"Tournament"."id"`. Alias it + * `.as("membersCount")` when selecting it directly. + */ +export function tournamentMembersCount( + eb: ExpressionBuilder, +) { + return eb + .case() + .when("Tournament.isFinalized", "=", 1) + .then( + eb + .selectFrom("TournamentResult") + .whereRef("TournamentResult.tournamentId", "=", "Tournament.id") + .select(({ fn }) => [ + fn.count("TournamentResult.userId").distinct().as("count"), + ]), + ) + .else( + tournamentCheckedInTeams(eb) + .innerJoin( + "TournamentTeamMember", + "TournamentTeamMember.tournamentTeamId", + "TournamentTeam.id", + ) + .select(({ fn }) => [ + fn + .count("TournamentTeamMember.userId") + .distinct() + .as("count"), + ]), + ) + .end(); +} + /** Resolves the name a user is shown under in tournaments: `tournamentName` when set, otherwise `username`. */ export function tournamentUsername(alias = "User") { return sql`coalesce(${sql.ref(`${alias}.tournamentName`)}, ${sql.ref( diff --git a/apps/web/src/lib/utils/format-distance.ts b/apps/web/src/lib/utils/format-distance.ts new file mode 100644 index 000000000..151c2e899 --- /dev/null +++ b/apps/web/src/lib/utils/format-distance.ts @@ -0,0 +1,10 @@ +import { formatDistanceToNow } from "date-fns"; + +/** + * Localized "x minutes ago" style distance. Currently formats with date-fns' + * default (English) locale; non-English date-fns locales load with the wider + * i18n port. + */ +export function formatDistanceToNowLocalized(date: Date) { + return formatDistanceToNow(date, { addSuffix: true }); +} diff --git a/apps/web/src/lib/utils/git-commit.ts b/apps/web/src/lib/utils/git-commit.ts new file mode 100644 index 000000000..f274b1a8a --- /dev/null +++ b/apps/web/src/lib/utils/git-commit.ts @@ -0,0 +1,8 @@ +declare const __GIT_COMMIT__: string; + +/** + * Commit the running bundle was built from. Inlined at build time, meaning the + * client and the server report their own build's commit. Empty string outside + * of deployed builds (e.g. local development). + */ +export const GIT_COMMIT = __GIT_COMMIT__; diff --git a/apps/web/src/lib/utils/urls.ts b/apps/web/src/lib/utils/urls.ts index acc4c1a04..9dbf0ceaa 100644 --- a/apps/web/src/lib/utils/urls.ts +++ b/apps/web/src/lib/utils/urls.ts @@ -4,9 +4,53 @@ const STATIC_ASSETS_URL = Config.staticAssetsUrl; export const LEADERBOARDS_PAGE = "/leaderboards"; export const SENDOUQ_PAGE = "/q"; +export const LOG_IN_URL = "/auth"; +export const API_PAGE = "/api"; +export const FAQ_PAGE = "/faq"; +export const WELCOME_PAGE = "/welcome"; +export const SUPPORT_PAGE = "/support"; +export const CONTRIBUTIONS_PAGE = "/contributions"; +export const EVENTS_PAGE = "/events"; +export const FRIENDS_PAGE = "/friends"; +export const SETTINGS_PAGE = "/settings"; +export const NOTIFICATIONS_URL = "/notifications"; +export const PLANNER_URL = "/plans"; +export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop"; + +export const SENDOU_INK_BASE_URL = "https://sendou.ink"; +export const SENDOU_INK_DISCORD_URL = "https://discord.gg/sendou"; +export const SENDOU_INK_GITHUB_URL = "https://github.com/sendou-ink/sendou.ink"; +export const NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL = + "https://en-americas-support.nintendo.com/app/answers/detail/a_id/63454"; + +export const impersonateUrl = (idToLogInAs: number) => + `/auth/impersonate?id=${idToLogInAs}`; +export const twitchUrl = (accountName: string) => + `https://twitch.tv/${accountName}`; +export const tournamentInfoPage = (tournamentId: number) => + `/to/${tournamentId}/info`; +export const tournamentPage = (tournamentId: number) => `/to/${tournamentId}`; +export const tournamentStreamsPage = (tournamentId: number) => + `/to/${tournamentId}/streams`; +export const SENDOUQ_STREAMS_PAGE = "/q/streams"; +export const sendouQMatchPage = (id: number) => `${SENDOUQ_PAGE}/match/${id}`; + +export const discordAvatarUrl = ({ + discordId, + discordAvatar, + size, +}: { + discordId: string; + discordAvatar: string; + size: "lg" | "sm"; +}) => + `https://cdn.discordapp.com/avatars/${discordId}/${ + discordAvatar + }.webp${size === "lg" ? "?size=240" : "?size=80"}`; export const BLANK_IMAGE_URL = `${STATIC_ASSETS_URL}/img/blank.gif`; export const TIER_PLUS_URL = `${STATIC_ASSETS_URL}/img/tiers/plus`; +export const SENDOU_LOVE_EMOJI_PATH = `${STATIC_ASSETS_URL}/img/layout/sendou_love`; export const FIRST_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/first.svg`; export const SECOND_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/second.svg`; diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index 0a65dd4fe..2fcb82e81 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -1,8 +1,15 @@ +import { readSessionCookie } from "#lib/features/auth/session.server.ts"; +import { resolveSidebarData } from "#lib/features/sidebar/sidebar.server.ts"; +import { getTheme } from "#lib/features/theme/theme.server.ts"; import type { LayoutServerLoad } from "./$types"; -export const load: LayoutServerLoad = ({ locals }) => { +const SIDENAV_COOKIE_NAME = "sidenav"; + +export const load: LayoutServerLoad = async ({ locals, cookies }) => { const user = locals.user; + const sidenavSession = readSessionCookie(cookies.get(SIDENAV_COOKIE_NAME)); + return { user: user ? { @@ -21,5 +28,8 @@ export const load: LayoutServerLoad = ({ locals }) => { createdAt: user.createdAt, } : undefined, + sidenavCollapsed: sidenavSession?.collapsed === true, + sidebar: await resolveSidebarData(user?.id ?? null), + theme: getTheme(cookies), }; }; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index faa2f563b..368e96eb3 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -5,8 +5,40 @@ import "#lib/styles/common.css"; import "#lib/styles/utils.css"; import "#lib/styles/flags.css"; + import "nprogress/nprogress.css"; + + import NProgress from "nprogress"; + import { navigating, page } from "$app/state"; + import HydrationTestIndicator from "#lib/components/HydrationTestIndicator.svelte"; + import Layout from "#lib/components/layout/Layout.svelte"; + import PwaLinks from "#lib/components/PwaLinks.svelte"; + import ThemeHead from "#lib/features/theme/ThemeHead.svelte"; + import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts"; let { children } = $props(); + + const theme = $derived( + (page.data as { theme?: "dark" | "light" | null }).theme ?? null, + ); + + NProgress.configure({ parent: `#nprogress-anchor`, showSpinner: false }); + + $effect(() => { + if (navigating.to) { + NProgress.start(); + } else { + NProgress.done(); + } + }); -{@render children()} + + + +{#if IS_E2E_TEST_RUN} + +{/if} + + + {@render children()} + diff --git a/apps/web/src/routes/leaderboards/+page.ts b/apps/web/src/routes/leaderboards/+page.ts new file mode 100644 index 000000000..6f74b9ba7 --- /dev/null +++ b/apps/web/src/routes/leaderboards/+page.ts @@ -0,0 +1,14 @@ +import { LEADERBOARDS_PAGE, navIconUrl } from "#lib/utils/urls.ts"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = () => { + return { + breadcrumbs: [ + { + type: "IMAGE" as const, + imgPath: navIconUrl("leaderboards"), + href: LEADERBOARDS_PAGE, + }, + ], + }; +}; diff --git a/apps/web/static/app.webmanifest b/apps/web/static/app.webmanifest new file mode 100644 index 000000000..08d6227f6 --- /dev/null +++ b/apps/web/static/app.webmanifest @@ -0,0 +1,51 @@ +{ + "name": "sendou.ink", + "start_url": "/", + "display": "standalone", + "id": "sendou.ink", + "description": "Competitive Splatoon Hub", + "theme_color": "#010115", + "background_color": "#02011e", + "icons": [ + { + "src": "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/img/app-icon.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "shortcuts": [ + { + "name": "Builds", + "url": "/builds", + "icons": [ + { + "src": "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/img/pwa-icons/builds.png", + "type": "image/png", + "sizes": "96x96" + } + ] + }, + { + "name": "Build Analyzer", + "url": "/analyzer", + "icons": [ + { + "src": "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/img/pwa-icons/analyzer.png", + "type": "image/png", + "sizes": "96x96" + } + ] + }, + { + "name": "Calendar", + "url": "/calendar", + "icons": [ + { + "src": "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/img/pwa-icons/calendar.png", + "type": "image/png", + "sizes": "96x96" + } + ] + } + ] +} diff --git a/apps/web/static/favicon.ico b/apps/web/static/favicon.ico new file mode 100644 index 000000000..ff9d6d941 Binary files /dev/null and b/apps/web/static/favicon.ico differ diff --git a/apps/web/static/robots.txt b/apps/web/static/robots.txt new file mode 100644 index 000000000..14267e903 --- /dev/null +++ b/apps/web/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / \ No newline at end of file diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8def3c3c9..a97001178 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,6 +4,9 @@ import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; export default defineConfig(({ mode }) => ({ + define: { + __GIT_COMMIT__: JSON.stringify(process.env.RENDER_GIT_COMMIT ?? ""), + }, plugins: [ paraglideVitePlugin({ project: "./project.inlang", diff --git a/packages/components/src/Select.svelte b/packages/components/src/Select.svelte index 1f9dfafe0..547ffc2a1 100644 --- a/packages/components/src/Select.svelte +++ b/packages/components/src/Select.svelte @@ -274,7 +274,7 @@ aria-haspopup="listbox" aria-expanded={open} aria-label={ariaLabel} - aria-required={isRequired || undefined} + data-required={isRequired || undefined} onclick={() => setOpen(!open)} onkeydown={onTriggerKeydown} > diff --git a/tooling/codemods/src/remote-scaffold.ts b/tooling/codemods/src/remote-scaffold.ts index c5d9d6d71..aed32d19e 100644 --- a/tooling/codemods/src/remote-scaffold.ts +++ b/tooling/codemods/src/remote-scaffold.ts @@ -57,10 +57,7 @@ const queries = loaderFiles.map((file) => ({ })); const commands = actionFiles.flatMap((file) => { - const source = readFileSync( - join(featureDir, "actions", file), - "utf8", - ); + const source = readFileSync(join(featureDir, "actions", file), "utf8"); const schemaImports = source.match(/from "[^"]*-schemas"/g) ?? []; const schemaSources = [ source, diff --git a/tooling/differ/src/differ.ts b/tooling/differ/src/differ.ts index 915eb4b3f..4c2c19b23 100644 --- a/tooling/differ/src/differ.ts +++ b/tooling/differ/src/differ.ts @@ -570,11 +570,51 @@ function extractHead(html: string): string { return match?.[1] ?? ""; } +/** `` values that carry SEO/PWA meaning and must survive the cutover. */ +const MEANINGFUL_LINK_RELS = new Set([ + "canonical", + "alternate", + "manifest", + "icon", + "apple-touch-icon", + "apple-touch-startup-image", +]); + +/** + * Reduces a `` to the tags that must match across the two apps: the + * title and every meta plus SEO/PWA-meaningful links. Framework output + * (hashed stylesheets, module preloads, inline scripts) legitimately differs + * between React Router and SvelteKit and is dropped. Tags are compared as a + * sorted set since the frameworks emit them in different orders. + */ function normalizeHead(head: string, origins: string[]): string { - return normalizeOrigins(head, origins) - .replace(/>\s+\n<") - .replace(/[ \t]+/g, " ") - .trim(); + const withoutComments = normalizeOrigins(head, origins).replace( + //g, + "", + ); + + const tags: string[] = []; + + const titleMatch = withoutComments.match(/]*>([\s\S]*?)<\/title>/i); + if (titleMatch) { + tags.push(`${titleMatch[1].trim()}`); + } + + for (const match of withoutComments.matchAll(/<(meta|link)\b[^>]*>/gi)) { + const tag = match[0] + .replace(/\s+/g, " ") + .replace(/\s*\/?>$/, ">") + .trim(); + + if (match[1].toLowerCase() === "link") { + const rel = tag.match(/\brel="([^"]*)"/i)?.[1]?.toLowerCase(); + if (!rel || !MEANINGFUL_LINK_RELS.has(rel)) continue; + } + + tags.push(tag); + } + + return tags.sort().join("\n"); } function normalizeOrigins(text: string, origins: string[]): string { diff --git a/tooling/differ/src/servers.ts b/tooling/differ/src/servers.ts index 88275cee7..b293c4769 100644 --- a/tooling/differ/src/servers.ts +++ b/tooling/differ/src/servers.ts @@ -20,8 +20,18 @@ export async function startServers( const servers = ( [ - { name: "left", app: "web-react", port: config.leftPort, dbFile: dbFiles.left }, - { name: "right", app: config.rightApp, port: config.rightPort, dbFile: dbFiles.right }, + { + name: "left", + app: "web-react", + port: config.leftPort, + dbFile: dbFiles.left, + }, + { + name: "right", + app: config.rightApp, + port: config.rightPort, + dbFile: dbFiles.right, + }, ] as const ).map(({ name, app, port, dbFile }): AppServer => { const isSvelteApp = app === "web"; @@ -43,9 +53,7 @@ export async function startServers( NODE_OPTIONS: "--max-old-space-size=1024", // the db files live in the web-react dir either way (they are // produced by its seed scripts) - DB_PATH: isSvelteApp - ? `${config.webReactDir}/${dbFile}` - : dbFile, + DB_PATH: isSvelteApp ? `${config.webReactDir}/${dbFile}` : dbFile, PORT: String(port), DISCORD_CLIENT_ID: "123", DISCORD_CLIENT_SECRET: "secret",