From 8f3caa4683e2a29b6cf182e66b82040bd391ccb4 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sun, 16 Aug 2026 12:09:57 +0300
Subject: [PATCH] App shell: sidenav/header/footer layout, sidebar data path,
notifications bell
- Layout.svelte composes SideNav (events/friends/streams), MobileNav, header (logo, breadcrumbs, collapse buttons, TopNavMenus, TopRightButtons, nprogress anchor) and Footer (patrons via remote query)
- sidebar server data ported: resolveSidebarData + StreamRanking + friend/scrim/tournament/stream repositories, wired through the root layout load with the signed sidenav cookie
- notifications: repository + getNotifications remote query, bell popover with unseen dot, notification link/icon mapping ported
- theme: ThemeHead with color-scheme meta + no-flash script, byte-compatible theme cookie in layout data
- PWA head links extracted from the React root; git commit define; nprogress driven by navigating state
- differ head diff made semantic (title/meta/SEO-links as a sorted set) since framework asset links legitimately differ
---
.../src/lib/components/NotificationDot.svelte | 58 ++
apps/web/src/lib/components/PwaLinks.svelte | 20 +
apps/web/src/lib/components/TierPill.svelte | 191 +++++
.../lib/components/icons/DiscordIcon.svelte | 16 +
.../lib/components/icons/GitHubIcon.svelte | 17 +
.../lib/components/icons/PatreonIcon.svelte | 17 +
.../lib/components/icons/TwitchIcon.svelte | 16 +
.../components/layout/AnythingAdder.svelte | 67 ++
.../lib/components/layout/EventsList.svelte | 99 +++
.../src/lib/components/layout/Footer.svelte | 258 ++++++
.../lib/components/layout/FriendMenu.svelte | 148 ++++
.../lib/components/layout/GhostTabBar.svelte | 46 ++
.../src/lib/components/layout/Layout.svelte | 746 ++++++++++++++++++
.../lib/components/layout/ListButton.svelte | 145 ++++
.../src/lib/components/layout/ListLink.svelte | 178 +++++
.../layout/LogInButtonContainer.svelte | 14 +
.../lib/components/layout/MobileNav.svelte | 384 +++++++++
.../layout/MobileNavMenuOverlay.svelte | 335 ++++++++
.../lib/components/layout/MobilePanel.svelte | 154 ++++
.../lib/components/layout/MobileTab.svelte | 108 +++
.../src/lib/components/layout/SideNav.svelte | 90 +++
.../components/layout/SideNavFooter.svelte | 95 +++
.../components/layout/SideNavHeader.svelte | 75 ++
.../components/layout/SideNavUserPanel.svelte | 140 ++++
.../components/layout/StreamListItems.svelte | 198 +++++
.../lib/components/layout/TopNavMenus.svelte | 355 +++++++++
.../components/layout/TopRightButtons.svelte | 307 +++++++
.../src/lib/components/layout/layout-types.ts | 84 ++
.../src/lib/components/layout/nav-items.ts | 111 +++
.../src/lib/components/pwa-splash-screens.ts | 142 ++++
.../associations/associations-types.ts | 23 +
.../lib/features/calendar/calendar-types.ts | 44 ++
.../features/core-streams/streams.server.ts | 39 +
.../friends/FriendRepository.server.ts | 130 +++
.../lib/features/friends/friends-constants.ts | 40 +
.../features/friends/friends-utils.server.ts | 116 +++
.../front-page/ShowcaseTournaments.server.ts | 343 ++++++++
.../features/front-page/front-page.remote.ts | 6 +
.../LiveStreamRepository.server.ts | 21 +
.../NotificationRepository.server.ts | 31 +
.../components/NotificationContent.svelte | 125 +++
.../components/NotificationItem.svelte | 123 +++
.../notifications/notifications-constants.ts | 4 +
.../notifications/notifications-types.ts | 115 +++
.../notifications/notifications-utils.ts | 126 +++
.../notifications/notifications.remote.ts | 20 +
apps/web/src/lib/features/scrims/Scrim.ts | 32 +
.../scrims/ScrimPostRepository.server.ts | 312 ++++++++
.../src/lib/features/scrims/scrims-types.ts | 84 ++
.../QStreamsRepository.server.ts | 42 +
.../sendouq-streams/streams.server.ts | 158 ++++
.../lib/features/sendouq/q-groups.server.ts | 87 ++
.../src/lib/features/sidebar/StreamRanking.ts | 88 +++
.../lib/features/sidebar/sidebar.server.ts | 447 +++++++++++
.../src/lib/features/theme/ThemeHead.svelte | 41 +
.../tentativeTiers.server.ts | 60 ++
.../SavedCalendarEventRepository.server.ts | 44 ++
.../tournament/TournamentRepository.server.ts | 152 ++++
.../features/tournament/tournament-types.ts | 28 +
.../features/tournament/tournament-utils.ts | 42 +
.../lib/features/trophies/trophies-utils.ts | 11 +
.../user-page/UserRepository.server.ts | 11 +
apps/web/src/lib/server/db/tables.ts | 268 +++++++
apps/web/src/lib/server/kysely.ts | 110 +++
apps/web/src/lib/utils/format-distance.ts | 10 +
apps/web/src/lib/utils/git-commit.ts | 8 +
apps/web/src/lib/utils/urls.ts | 44 ++
apps/web/src/routes/+layout.server.ts | 12 +-
apps/web/src/routes/+layout.svelte | 34 +-
apps/web/src/routes/leaderboards/+page.ts | 14 +
apps/web/static/app.webmanifest | 51 ++
apps/web/static/favicon.ico | Bin 0 -> 15406 bytes
apps/web/static/robots.txt | 2 +
apps/web/vite.config.ts | 3 +
packages/components/src/Select.svelte | 2 +-
tooling/codemods/src/remote-scaffold.ts | 5 +-
tooling/differ/src/differ.ts | 48 +-
tooling/differ/src/servers.ts | 18 +-
78 files changed, 8172 insertions(+), 16 deletions(-)
create mode 100644 apps/web/src/lib/components/NotificationDot.svelte
create mode 100644 apps/web/src/lib/components/PwaLinks.svelte
create mode 100644 apps/web/src/lib/components/TierPill.svelte
create mode 100644 apps/web/src/lib/components/icons/DiscordIcon.svelte
create mode 100644 apps/web/src/lib/components/icons/GitHubIcon.svelte
create mode 100644 apps/web/src/lib/components/icons/PatreonIcon.svelte
create mode 100644 apps/web/src/lib/components/icons/TwitchIcon.svelte
create mode 100644 apps/web/src/lib/components/layout/AnythingAdder.svelte
create mode 100644 apps/web/src/lib/components/layout/EventsList.svelte
create mode 100644 apps/web/src/lib/components/layout/Footer.svelte
create mode 100644 apps/web/src/lib/components/layout/FriendMenu.svelte
create mode 100644 apps/web/src/lib/components/layout/GhostTabBar.svelte
create mode 100644 apps/web/src/lib/components/layout/Layout.svelte
create mode 100644 apps/web/src/lib/components/layout/ListButton.svelte
create mode 100644 apps/web/src/lib/components/layout/ListLink.svelte
create mode 100644 apps/web/src/lib/components/layout/LogInButtonContainer.svelte
create mode 100644 apps/web/src/lib/components/layout/MobileNav.svelte
create mode 100644 apps/web/src/lib/components/layout/MobileNavMenuOverlay.svelte
create mode 100644 apps/web/src/lib/components/layout/MobilePanel.svelte
create mode 100644 apps/web/src/lib/components/layout/MobileTab.svelte
create mode 100644 apps/web/src/lib/components/layout/SideNav.svelte
create mode 100644 apps/web/src/lib/components/layout/SideNavFooter.svelte
create mode 100644 apps/web/src/lib/components/layout/SideNavHeader.svelte
create mode 100644 apps/web/src/lib/components/layout/SideNavUserPanel.svelte
create mode 100644 apps/web/src/lib/components/layout/StreamListItems.svelte
create mode 100644 apps/web/src/lib/components/layout/TopNavMenus.svelte
create mode 100644 apps/web/src/lib/components/layout/TopRightButtons.svelte
create mode 100644 apps/web/src/lib/components/layout/layout-types.ts
create mode 100644 apps/web/src/lib/components/layout/nav-items.ts
create mode 100644 apps/web/src/lib/components/pwa-splash-screens.ts
create mode 100644 apps/web/src/lib/features/associations/associations-types.ts
create mode 100644 apps/web/src/lib/features/calendar/calendar-types.ts
create mode 100644 apps/web/src/lib/features/core-streams/streams.server.ts
create mode 100644 apps/web/src/lib/features/friends/FriendRepository.server.ts
create mode 100644 apps/web/src/lib/features/friends/friends-constants.ts
create mode 100644 apps/web/src/lib/features/friends/friends-utils.server.ts
create mode 100644 apps/web/src/lib/features/front-page/ShowcaseTournaments.server.ts
create mode 100644 apps/web/src/lib/features/front-page/front-page.remote.ts
create mode 100644 apps/web/src/lib/features/live-streams/LiveStreamRepository.server.ts
create mode 100644 apps/web/src/lib/features/notifications/NotificationRepository.server.ts
create mode 100644 apps/web/src/lib/features/notifications/components/NotificationContent.svelte
create mode 100644 apps/web/src/lib/features/notifications/components/NotificationItem.svelte
create mode 100644 apps/web/src/lib/features/notifications/notifications-constants.ts
create mode 100644 apps/web/src/lib/features/notifications/notifications-types.ts
create mode 100644 apps/web/src/lib/features/notifications/notifications-utils.ts
create mode 100644 apps/web/src/lib/features/notifications/notifications.remote.ts
create mode 100644 apps/web/src/lib/features/scrims/Scrim.ts
create mode 100644 apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts
create mode 100644 apps/web/src/lib/features/scrims/scrims-types.ts
create mode 100644 apps/web/src/lib/features/sendouq-streams/QStreamsRepository.server.ts
create mode 100644 apps/web/src/lib/features/sendouq-streams/streams.server.ts
create mode 100644 apps/web/src/lib/features/sendouq/q-groups.server.ts
create mode 100644 apps/web/src/lib/features/sidebar/StreamRanking.ts
create mode 100644 apps/web/src/lib/features/sidebar/sidebar.server.ts
create mode 100644 apps/web/src/lib/features/theme/ThemeHead.svelte
create mode 100644 apps/web/src/lib/features/tournament-organization/tentativeTiers.server.ts
create mode 100644 apps/web/src/lib/features/tournament/SavedCalendarEventRepository.server.ts
create mode 100644 apps/web/src/lib/features/tournament/TournamentRepository.server.ts
create mode 100644 apps/web/src/lib/features/tournament/tournament-types.ts
create mode 100644 apps/web/src/lib/features/tournament/tournament-utils.ts
create mode 100644 apps/web/src/lib/features/trophies/trophies-utils.ts
create mode 100644 apps/web/src/lib/utils/format-distance.ts
create mode 100644 apps/web/src/lib/utils/git-commit.ts
create mode 100644 apps/web/src/routes/leaderboards/+page.ts
create mode 100644 apps/web/static/app.webmanifest
create mode 100644 apps/web/static/favicon.ico
create mode 100644 apps/web/static/robots.txt
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}
+
+{/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)}
+
+
+ {#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 @@
+
+
+
+
+
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 @@
+
+
+
+ {#each { length: tabCount } as _, i (i)}
+
+
+ {/each}
+
+
+
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()}
+
+{/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}
+ Placeholder
+ {/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()}
+
+
+
+
+ {@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 @@
+
+
+
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 @@
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
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 @@
+
+
+
+
+
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}
+
+
+{: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