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
This commit is contained in:
Kalle
2026-08-16 12:09:57 +03:00
parent 35ffc136be
commit 8f3caa4683
78 changed files with 8172 additions and 16 deletions

View File

@@ -0,0 +1,58 @@
<script lang="ts">
interface Props {
class?: string;
testId?: string;
}
let { class: className, testId }: Props = $props();
</script>
<span class={["dotWrapper", className]} data-testid={testId}>
<span class="pulse"></span>
<span class="dot"></span>
</span>
<style>
.dotWrapper {
position: absolute;
top: var(--dot-top, -2px);
right: var(--dot-right, -2px);
width: 8px;
height: 8px;
pointer-events: none;
}
.dot {
display: block;
width: 100%;
height: 100%;
position: absolute;
background-color: var(--color-text-accent);
border-radius: 100%;
outline: 2px solid var(--color-bg);
}
.pulse {
display: block;
width: calc(100% + 20px);
height: calc(100% + 20px);
position: absolute;
top: -10px;
left: -10px;
border-radius: 100%;
background-color: var(--color-text-accent);
animation: pulse 2s infinite;
opacity: 0;
}
@keyframes pulse {
from {
transform: scale(0);
opacity: 1;
}
70% {
transform: scale(1);
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Config } from "#lib/config.ts";
import { PWA_SPLASH_SCREENS } from "./pwa-splash-screens.ts";
const APP_ICON_URL = `${Config.staticAssetsUrl}/img/app-icon.png`;
const splashScreenUrl = (fileName: string) =>
`${Config.staticAssetsUrl}/img/splash-screens/${fileName}`;
</script>
<svelte:head>
<link rel="manifest" href="/app.webmanifest" />
<link rel="apple-touch-icon" href={APP_ICON_URL} />
{#each PWA_SPLASH_SCREENS as { media, fileName } (fileName)}
<link
rel="apple-touch-startup-image"
{media}
href={splashScreenUrl(fileName)}
/>
{/each}
</svelte:head>

View File

@@ -0,0 +1,191 @@
<script lang="ts" module>
const NUMBER_TO_TIER: Record<number, string> = {
1: "X",
2: "S+",
3: "S",
4: "A+",
5: "A",
6: "B+",
7: "B",
8: "C+",
9: "C",
};
const TIER_STYLE_CLASS: Record<number, string> = {
1: "tierX",
2: "tierSPlus",
3: "tierS",
4: "tierAPlus",
5: "tierA",
6: "tierBPlus",
7: "tierB",
8: "tierCPlus",
9: "tierC",
};
const POLISHED_TIERS = [1, 2, 3];
</script>
<script lang="ts">
import { dynamicMessage } from "#lib/modules/i18n/messages.ts";
interface Props {
tier: number;
isTentative?: boolean;
withoutAnimation?: boolean;
}
let { tier, isTentative = false, withoutAnimation = false }: Props = $props();
const tierName = $derived(NUMBER_TO_TIER[tier] ?? "");
const tierClass = $derived(TIER_STYLE_CLASS[tier] ?? "");
const displayName = $derived(isTentative ? `~${tierName}` : tierName);
</script>
<div
class={[
"pill",
tierClass,
{ polished: POLISHED_TIERS.includes(tier), withoutAnimation },
]}
data-testid={isTentative ? "tentative-tier" : "confirmed-tier"}
title={dynamicMessage(
isTentative ? "common_tier_tentative" : "common_tier_confirmed",
).replace("{tierName}", tierName)}
>
{displayName}
</div>
<style>
:global(:root) {
--tier-bg-1: hsl(45, 100%, 47%);
--tier-bg-2: hsl(280, 90%, 44%);
--tier-bg-3: hsl(280, 65%, 52%);
--tier-bg-4: hsl(212, 95%, 44%);
--tier-bg-5: hsl(212, 80%, 51%);
--tier-bg-6: hsl(145, 80%, 34%);
--tier-bg-7: hsl(145, 60%, 41%);
--tier-bg-8: hsl(220, 10%, 40%);
--tier-bg-9: hsl(220, 8%, 49%);
--tier-text-1: hsl(45, 100%, 12%);
--tier-text-2: hsl(280, 100%, 94%);
--tier-text-3: hsl(280, 100%, 95%);
--tier-text-4: hsl(212, 100%, 93%);
--tier-text-5: hsl(212, 100%, 95%);
--tier-text-6: hsl(145, 85%, 92%);
--tier-text-7: hsl(145, 75%, 94%);
--tier-text-8: hsl(220, 15%, 92%);
--tier-text-9: hsl(220, 15%, 95%);
}
.pill {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
border-radius: var(--radius-selector);
padding: 0 var(--s-1-5);
height: var(--selector-size-sm);
display: grid;
place-items: center;
min-width: 33px;
text-align: center;
}
.tierX {
--_polished-bg: var(--tier-bg-1);
background-color: var(--tier-bg-1);
color: var(--tier-text-1);
}
.tierSPlus {
--_polished-bg: var(--tier-bg-2);
background-color: var(--tier-bg-2);
color: var(--tier-text-2);
}
.tierS {
--_polished-bg: var(--tier-bg-3);
background-color: var(--tier-bg-3);
color: var(--tier-text-3);
}
.tierAPlus {
background-color: var(--tier-bg-4);
color: var(--tier-text-4);
}
.tierA {
background-color: var(--tier-bg-5);
color: var(--tier-text-5);
}
.tierBPlus {
background-color: var(--tier-bg-6);
color: var(--tier-text-6);
}
.tierB {
background-color: var(--tier-bg-7);
color: var(--tier-text-7);
}
.tierCPlus {
background-color: var(--tier-bg-8);
color: var(--tier-text-8);
}
.tierC {
background-color: var(--tier-bg-9);
color: var(--tier-text-9);
}
.polished {
position: relative;
overflow: hidden;
background-image: linear-gradient(
160deg,
hsl(from var(--_polished-bg) calc(h + 2) s calc(l + 14)),
var(--_polished-bg) 50%,
hsl(from var(--_polished-bg) calc(h - 3) s calc(l - 9))
);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 35%,
hsl(0, 0%, 100%, 0.5) 50%,
transparent 65%
);
transform: translateX(-100%);
animation: shine-sweep 6s ease-in-out infinite;
}
}
@keyframes shine-sweep {
0% {
transform: translateX(-100%);
}
12% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
.withoutAnimation::after {
animation: none;
visibility: hidden;
}
@media (prefers-reduced-motion: reduce) {
.polished::after {
animation: none;
visibility: hidden;
}
}
</style>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
let { class: className }: { class?: string } = $props();
</script>
<svg
class={className}
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"
/>
</svg>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
let { class: className }: { class?: string } = $props();
</script>
<svg
stroke="currentColor"
fill="currentColor"
stroke-width="0"
version="1.1"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
class={className}
>
<path
d="M8 0.198c-4.418 0-8 3.582-8 8 0 3.535 2.292 6.533 5.471 7.591 0.4 0.074 0.547-0.174 0.547-0.385 0-0.191-0.008-0.821-0.011-1.489-2.226 0.484-2.695-0.944-2.695-0.944-0.364-0.925-0.888-1.171-0.888-1.171-0.726-0.497 0.055-0.486 0.055-0.486 0.803 0.056 1.226 0.824 1.226 0.824 0.714 1.223 1.872 0.869 2.328 0.665 0.072-0.517 0.279-0.87 0.508-1.070-1.777-0.202-3.645-0.888-3.645-3.954 0-0.873 0.313-1.587 0.824-2.147-0.083-0.202-0.357-1.015 0.077-2.117 0 0 0.672-0.215 2.201 0.82 0.638-0.177 1.322-0.266 2.002-0.269 0.68 0.003 1.365 0.092 2.004 0.269 1.527-1.035 2.198-0.82 2.198-0.82 0.435 1.102 0.162 1.916 0.079 2.117 0.513 0.56 0.823 1.274 0.823 2.147 0 3.073-1.872 3.749-3.653 3.947 0.287 0.248 0.543 0.735 0.543 1.481 0 1.070-0.009 1.932-0.009 2.195 0 0.213 0.144 0.462 0.55 0.384 3.177-1.059 5.466-4.057 5.466-7.59 0-4.418-3.582-8-8-8z"
/>
</svg>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
let { class: className }: { class?: string } = $props();
</script>
<svg
stroke="currentColor"
fill="currentColor"
stroke-width="0"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class={className}
>
<path
d="M0 .48v23.04h4.22V.48zm15.385 0c-4.764 0-8.641 3.88-8.641 8.65 0 4.755 3.877 8.623 8.641 8.623 4.75 0 8.615-3.868 8.615-8.623C24 4.36 20.136.48 15.385.48z"
/>
</svg>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
let { class: className }: { class?: string } = $props();
</script>
<svg
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
class={className}
>
<path
d="M391.17,103.47H352.54v109.7h38.63ZM285,103H246.37V212.75H285ZM120.83,0,24.31,91.42V420.58H140.14V512l96.53-91.42h77.25L487.69,256V0ZM449.07,237.75l-77.22,73.12H294.61l-67.6,64v-64H140.14V36.58H449.07Z"
/>
</svg>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { Plus } from "@lucide/svelte";
import { Button, Menu, MenuItem } from "@sendou/components";
import Image from "#lib/components/Image.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { canAccessTrophies } from "#lib/features/trophies/trophies-utils.ts";
import { dynamicMessage } from "#lib/modules/i18n/messages.ts";
import { m } from "#lib/paraglide/messages.js";
import { navIconUrl, userPage } from "#lib/utils/urls.ts";
interface Props {
compact?: boolean;
}
let { compact }: Props = $props();
const user = $derived(loggedInUser());
const items = $derived.by(() => {
if (!user) return [];
const all = [
{ id: "tournament", icon: "medal", href: "/calendar/new/tournament" },
{ id: "organization", icon: "medal", href: "/org/new" },
{ id: "calendarEvent", icon: "calendar", href: "/calendar/new" },
{ id: "build", icon: "builds", href: `${userPage(user)}/builds/new` },
{ id: "team", icon: "t", href: "/t?new=true" },
{ id: "scrimPost", icon: "scrims", href: "/scrims/new" },
{ id: "association", icon: "associations", href: "/associations/new" },
{ id: "lfgPost", icon: "lfg", href: "/lfg/new" },
{ id: "art", icon: "art", href: "/art/new" },
{ id: "vod", icon: "vods", href: "/vods/new" },
{ id: "plusSuggestion", icon: "plus", href: "/plus/suggestions/new" },
canAccessTrophies(user)
? { id: "trophy", icon: "trophies", href: "/trophies/new" }
: null,
];
return all.filter((item) => item !== null);
});
</script>
{#if user}
<Menu>
{#snippet trigger(triggerProps)}
<Button
size="small"
shape={compact ? "square" : undefined}
testId="anything-adder-menu-button"
aria-expanded={triggerProps["aria-expanded"]}
aria-haspopup={triggerProps["aria-haspopup"]}
onclick={triggerProps.onclick}
>
{#snippet icon()}<Plus />{/snippet}
{#if !compact}{`${m.common_actions_addNew()}…`}{/if}
</Button>
{/snippet}
{#each items as item (item.id)}
<MenuItem href={item.href}>
{#snippet icon()}
<Image path={navIconUrl(item.icon)} alt="" width={20} height={20} />
{/snippet}
{dynamicMessage(`common_header_adder_${item.id}`)}
</MenuItem>
{/each}
</Menu>
{/if}

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { isToday, isTomorrow } from "date-fns";
import { getLocale } from "#lib/paraglide/runtime.js";
import { m } from "#lib/paraglide/messages.js";
import type { SidebarEvent } from "./layout-types.ts";
import ListLink from "./ListLink.svelte";
interface Props {
events: SidebarEvent[];
onclick?: () => void;
}
let { events, onclick }: Props = $props();
const dateFormatter = $derived(
new Intl.DateTimeFormat(getLocale(), {
weekday: "long",
month: "numeric",
day: "numeric",
}),
);
const timeFormatter = $derived(
new Intl.DateTimeFormat(getLocale(), {
hour: "numeric",
minute: "2-digit",
}),
);
function formatDayHeader(date: Date) {
if (isToday(date) || isTomorrow(date)) {
const rtf = new Intl.RelativeTimeFormat(getLocale(), {
numeric: "auto",
});
const str = rtf.format(isToday(date) ? 0 : 1, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
}
return dateFormatter.format(date);
}
function eventTitle(event: SidebarEvent) {
if (event.scrimStatus === "booked") {
return m.front_sideNav_scrimVs({ opponent: event.name });
}
if (event.scrimStatus === "looking") {
return m.front_sideNav_lookingForScrim();
}
if (event.scrimStatus === "requestPending") {
return m.front_sideNav_scrimRequestPending();
}
return event.name;
}
const groupedEvents = $derived.by(() => {
const groups = new Map<string, SidebarEvent[]>();
for (const event of events) {
const key = new Date(event.startsAt * 1000).toDateString();
const group = groups.get(key) ?? [];
group.push(event);
groups.set(key, group);
}
return [...groups.entries()];
});
</script>
{#if events.length === 0}
<div class="text-lighter text-sm p-2">
{m.front_sideNav_noEvents()}
</div>
{:else}
{#each groupedEvents as [dayKey, dayEvents] (dayKey)}
<div>
<div class="dayHeader">
{formatDayHeader(new Date(dayEvents[0].startsAt * 1000))}
</div>
{#each dayEvents as event (`${event.type}-${event.id}`)}
<ListLink
to={event.url}
imageUrl={event.logoUrl ?? undefined}
user={event.user ?? undefined}
subtitle={timeFormatter.format(event.startsAt * 1000)}
{onclick}
>
{eventTitle(event)}
</ListLink>
{/each}
</div>
{/each}
{/if}
<style>
.dayHeader {
padding: var(--s-2) var(--s-2) var(--s-1);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-high);
text-transform: uppercase;
letter-spacing: 0.05em;
}
</style>

View File

@@ -0,0 +1,258 @@
<script lang="ts">
import Image from "#lib/components/Image.svelte";
import DiscordIcon from "#lib/components/icons/DiscordIcon.svelte";
import GitHubIcon from "#lib/components/icons/GitHubIcon.svelte";
import PatreonIcon from "#lib/components/icons/PatreonIcon.svelte";
import { m } from "#lib/paraglide/messages.js";
import {
API_PAGE,
CONTRIBUTIONS_PAGE,
FAQ_PAGE,
NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL,
SENDOU_INK_DISCORD_URL,
SENDOU_INK_GITHUB_URL,
SENDOU_LOVE_EMOJI_PATH,
SUPPORT_PAGE,
userPage,
WELCOME_PAGE,
} from "#lib/utils/urls.ts";
interface Patron {
id: number;
username: string;
discordId: string;
customUrl?: string | null;
}
interface Props {
patrons?: Patron[];
gitCommit?: string | null;
}
let { patrons, gitCommit }: Props = $props();
const currentYear = new Date().getFullYear();
</script>
<footer class="footer">
<div class="linkList">
<a href={CONTRIBUTIONS_PAGE}>{m.common_pages_contributors()}</a>
<a href={FAQ_PAGE}>{m.common_pages_faq()}</a>
<a href={WELCOME_PAGE}>{m.common_pages_welcome()}</a>
<a href={API_PAGE}>{m.common_pages_api()}</a>
</div>
<div class="socials">
<a
class="socialLink"
href={SENDOU_INK_GITHUB_URL}
target="_blank"
rel="noreferrer"
>
<div class="socialHeader">
GitHub<p>{m.common_footer_github_subtitle()}</p>
</div>
<GitHubIcon class="socialIcon" />
</a>
<a
class="socialLink"
href={SENDOU_INK_DISCORD_URL}
target="_blank"
rel="noreferrer"
>
<div class="socialHeader">
Discord<p>{m.common_footer_discord_subtitle()}</p>
</div>
<DiscordIcon class="socialIcon" />
</a>
<a class="socialLink" href={SUPPORT_PAGE}>
<div class="socialHeader">
Patreon<p>{m.common_footer_patreon_subtitle()}</p>
</div>
<PatreonIcon class="socialIcon" />
</a>
</div>
<div>
<h4 class="patronTitle">
{m.common_footer_thanks()}
<Image alt="" path={SENDOU_LOVE_EMOJI_PATH} width={24} height={24} />
</h4>
<ul class="patronList">
{#each patrons ?? [] as patron (patron.id)}
<li>
<a href={userPage(patron)} class="patron">
{patron.username}
</a>
</li>
{/each}
</ul>
</div>
<div class="copyrightNote">
<p>
sendou.ink © Copyright of Sendou and contributors 2019-{currentYear}.
Original content & source code is licensed under the AGPL-3.0 license.
</p>
<p>
Splatoon is trademark & © of Nintendo 2014-{currentYear}. sendou.ink is
not affiliated with Nintendo.
</p>
<p>
All tournaments hosted on sendou.ink are unofficial and are not sponsored
by or affiliated with Nintendo. Terms for participating in and viewing
Community Tournaments using Nintendo Games can be found here:
<a
href={NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL}
target="_blank"
rel="noreferrer"
>
{NINTENDO_COMMUNITY_TOURNAMENTS_GUIDELINES_URL}
</a>
</p>
</div>
{#if gitCommit}
<a
class="sourceLink"
href="{SENDOU_INK_GITHUB_URL}/commits/{gitCommit}/"
target="_blank"
rel="noreferrer"
>
{m.common_footer_version()}
{gitCommit.slice(0, 10)}
</a>
{/if}
</footer>
<style>
.footer {
display: flex;
flex-direction: column;
padding: var(--s-3);
background-color: var(--color-bg-high);
gap: var(--s-6);
margin-block-start: auto;
padding-block-end: var(--s-32);
}
.linkList {
display: flex;
justify-content: space-evenly;
font-size: var(--font-2xs);
& > * {
flex: 1;
text-align: center;
}
}
.socials {
display: flex;
justify-content: center;
gap: var(--s-2);
}
.footer :global(.socialIcon) {
height: 2.25rem;
transition: transform 0.25s ease-in-out;
}
.socialLink {
display: flex;
max-width: 10rem;
height: 12rem;
flex: 1 1 0;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: var(--s-4);
border-radius: var(--radius-box);
background-color: var(--color-bg-higher);
cursor: pointer;
font-size: var(--font-lg);
}
.socialLink:hover > :global(.socialIcon) {
transform: translateY(-0.3rem);
}
.socialHeader {
text-align: center;
& > p {
font-size: var(--font-2xs);
}
}
.patronTitle {
display: flex;
align-items: flex-end;
justify-content: center;
font-size: var(--font-sm);
font-weight: var(--weight-semi);
gap: var(--s-2);
}
.patronList {
display: flex;
max-width: 75vw;
flex-wrap: wrap;
justify-content: center;
padding: 0;
margin: 0 auto;
font-size: var(--font-xs);
gap: var(--s-1);
list-style: none;
margin-block-start: var(--s-2);
}
.patron {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.copyrightNote {
display: flex;
flex-direction: column;
color: var(--color-text-high);
font-size: var(--font-2xs);
text-align: center;
}
.sourceLink {
margin-inline: auto;
font-size: var(--font-2xs);
color: var(--color-text-high);
font-style: italic;
}
@media screen and (max-width: 640px) {
.socials {
flex-direction: column;
}
.socialLink {
max-width: initial;
flex-direction: row;
}
.socialLink:hover > :global(.socialIcon) {
transform: translateX(-0.3rem);
}
.socialHeader {
display: flex;
align-items: center;
gap: var(--s-2);
text-align: initial;
& > p {
margin-block-start: var(--s-1);
}
}
.footer :global(.socialIcon) {
height: 1.75rem;
}
}
</style>

View File

@@ -0,0 +1,148 @@
<script lang="ts" module>
type FriendActivityBadge = "MATCH" | "NEXT";
const ACTIVITY_BADGE: Record<FriendActivityType, FriendActivityBadge | null> =
{
SENDOUQ_MATCH: "MATCH",
TOURNAMENT_MATCH: "MATCH",
TOURNAMENT_WAITING: "NEXT",
SENDOUQ: null,
TOURNAMENT_SUB: null,
};
function resolveActivity(friend: {
activityType: FriendActivityType | null;
matchId: number | null;
tournamentId: number | null;
}) {
switch (friend.activityType) {
case "SENDOUQ_MATCH":
return friend.matchId
? ({
type: "view-match",
url: sendouQMatchPage(friend.matchId),
} as const)
: null;
case "TOURNAMENT_MATCH":
return friend.tournamentId && friend.matchId
? ({
type: "view-match",
url: `${tournamentPage(friend.tournamentId)}/matches/${friend.matchId}`,
} as const)
: null;
case "TOURNAMENT_WAITING":
return friend.tournamentId
? ({
type: "view-tournament",
url: tournamentPage(friend.tournamentId),
} as const)
: null;
case "SENDOUQ":
return { type: "join-sendouq" } as const;
case "TOURNAMENT_SUB":
return friend.tournamentId
? ({
type: "view-tournament",
url: `${tournamentPage(friend.tournamentId)}/subs`,
} as const)
: null;
default:
return null;
}
}
</script>
<script lang="ts">
import { Swords, User } from "@lucide/svelte";
import { Menu, MenuItem } from "@sendou/components";
import TwitchIcon from "#lib/components/icons/TwitchIcon.svelte";
import { m } from "#lib/paraglide/messages.js";
import { sendouQMatchPage, tournamentPage } from "#lib/utils/urls.ts";
import type { FriendActivityType } from "./layout-types.ts";
import ListButton from "./ListButton.svelte";
interface Props {
discordId: string;
discordAvatar: string | null;
customAvatarUrl: string | null;
name: string;
subtitle: string | null;
badge: string | null;
url: string;
activityType: FriendActivityType | null;
matchId: number | null;
tournamentId: number | null;
streamUrl: string | null;
onNavigate?: () => void;
}
let {
discordId,
discordAvatar,
customAvatarUrl,
name,
subtitle,
badge,
url,
activityType,
matchId,
tournamentId,
streamUrl,
onNavigate,
}: Props = $props();
const activityBadge = $derived(
activityType ? ACTIVITY_BADGE[activityType] : null,
);
const activity = $derived(
resolveActivity({ activityType, matchId, tournamentId }),
);
const badgeText = $derived(
streamUrl
? m.friends_friendsList_live()
: activityBadge === "MATCH"
? m.friends_friendsList_inMatch()
: activityBadge === "NEXT"
? m.friends_friendsList_nextMatch()
: badge,
);
</script>
<Menu>
{#snippet trigger(triggerProps)}
<ListButton
user={{ discordId, discordAvatar, customAvatarUrl }}
{subtitle}
badge={badgeText}
badgeVariant={streamUrl ? "warning" : "default"}
aria-expanded={triggerProps["aria-expanded"]}
aria-haspopup={triggerProps["aria-haspopup"]}
onclick={triggerProps.onclick}
>
{name}
</ListButton>
{/snippet}
<MenuItem href={url}>
{#snippet icon()}<User />{/snippet}
{m.friends_friendsList_viewUserPage()}
</MenuItem>
{#if streamUrl}
<MenuItem href={streamUrl}>
{#snippet icon()}<TwitchIcon />{/snippet}
{m.friends_friendsList_watchStream()}
</MenuItem>
{/if}
{#if activity?.type === "view-match"}
<MenuItem href={activity.url}>
{#snippet icon()}<Swords />{/snippet}
{m.friends_friendsList_viewMatch()}
</MenuItem>
{/if}
{#if activity?.type === "view-tournament"}
<MenuItem href={activity.url}>
{#snippet icon()}<Swords />{/snippet}
{m.friends_friendsList_viewTournament()}
</MenuItem>
{/if}
</Menu>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
interface Props {
tabCount: number;
onTabPress: (index: number) => void;
}
let { tabCount, onTabPress }: Props = $props();
</script>
<div class="ghostTabBar" aria-hidden="true">
{#each { length: tabCount } as _, i (i)}
<!-- svelte-ignore a11y_consider_explicit_label -- invisible hit targets inside an aria-hidden bar -->
<button
type="button"
data-testid="ghost-tab"
class="ghostTab"
tabindex={-1}
onclick={() => onTabPress(i)}
></button>
{/each}
</div>
<style>
.ghostTabBar {
position: fixed;
bottom: calc(0px - var(--layout-nav-height));
left: 0;
right: 0;
height: calc(var(--layout-nav-height) + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
display: flex;
justify-content: space-around;
align-items: center;
z-index: 100;
}
.ghostTab {
height: 100%;
flex: 1;
background: none;
border: none;
cursor: pointer;
opacity: 0;
padding: 0;
}
</style>

View File

@@ -0,0 +1,746 @@
<script lang="ts">
import { Calendar, ChevronRight, PanelLeft, Tv, Users } from "@lucide/svelte";
import type { Snippet } from "svelte";
import { page } from "$app/state";
import Image from "#lib/components/Image.svelte";
import NotificationDot from "#lib/components/NotificationDot.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { getPatrons } from "#lib/features/front-page/front-page.remote.ts";
import { getNotifications } from "#lib/features/notifications/notifications.remote.ts";
import { toNotificationRows } from "#lib/features/notifications/notifications-utils.ts";
import { m } from "#lib/paraglide/messages.js";
import { getLocale } from "#lib/paraglide/runtime.js";
import { GIT_COMMIT } from "#lib/utils/git-commit.ts";
import { EVENTS_PAGE, FRIENDS_PAGE } from "#lib/utils/urls.ts";
import Footer from "./Footer.svelte";
import FriendMenu from "./FriendMenu.svelte";
import type { NotificationRow, SidebarData } from "./layout-types.ts";
import ListLink from "./ListLink.svelte";
import MobileNav from "./MobileNav.svelte";
import SideNav from "./SideNav.svelte";
import SideNavFooter from "./SideNavFooter.svelte";
import SideNavHeader from "./SideNavHeader.svelte";
import SideNavUserPanel from "./SideNavUserPanel.svelte";
import StreamListItems from "./StreamListItems.svelte";
import TopNavMenus from "./TopNavMenus.svelte";
import TopRightButtons from "./TopRightButtons.svelte";
const MAX_DESKTOP_FRIENDS = 4;
interface Breadcrumb {
type: "IMAGE" | "TEXT";
imgPath?: string;
href: string;
text?: string;
identiconInput?: string;
}
interface Props {
children: Snippet;
}
let { children }: Props = $props();
const user = $derived(loggedInUser());
const sidebarData = $derived(
(page.data as { sidebar?: SidebarData }).sidebar,
);
const initialCollapsed =
(page.data as { sidenavCollapsed?: boolean }).sidenavCollapsed ?? false;
// svelte-ignore state_referenced_locally -- the cookie value seeds the initial state only
let sideNavCollapsed = $state(initialCollapsed);
let sideNavModalOpen = $state(false);
let hydrated = $state(false);
let notifications = $state<NotificationRow[] | null>(null);
$effect(() => {
hydrated = true;
});
// the bell data loads lazily like the React app's NotificationsProvider:
// after hydration, never blocking SSR
$effect(() => {
if (!user) {
notifications = null;
return;
}
void getNotifications().then((result) => {
notifications = result.notifications
? toNotificationRows(result.notifications)
: null;
});
});
const unseenIds = $derived(
notifications
?.filter((notification) => !notification.seen)
.map((notification) => notification.id) ?? [],
);
const showUnseenDot = $derived(unseenIds.length > 0);
const patrons = $derived(await getPatrons());
const events = $derived(sidebarData?.events ?? []);
const friends = $derived(sidebarData?.friends ?? []);
const streams = $derived(sidebarData?.streams ?? []);
const unseenFriendRequests = $derived(
sidebarData?.incomingFriendRequestIds.length ?? 0,
);
const isFrontPage = $derived(page.url.pathname === "/");
const breadcrumbs = $derived(
((page.data as { breadcrumbs?: Breadcrumb[] }).breadcrumbs ?? []),
);
const currentPageText = $derived(breadcrumbs.at(-1)?.text);
function unseenRequestsLabel(count: number) {
return count === 1
? m.friends_unseenRequests_one({ count })
: m.friends_unseenRequests_other({ count });
}
function toggleSideNavCollapsed() {
sideNavCollapsed = !sideNavCollapsed;
void fetch("/sidenav", {
method: "POST",
body: new URLSearchParams({ collapsed: String(sideNavCollapsed) }),
});
}
// mobile hide-on-scroll header, ported from the React useNavOffset
let headerElement = $state<HTMLElement | null>(null);
let navOffset = $state(0);
$effect(() => {
const MOBILE_BREAKPOINT = 600;
const NAV_HEIGHT_FALLBACK = 55;
const SCROLL_THRESHOLD_PX = 200;
let lastScrollY = 0;
let scrollAccumulator = 0;
const handleScroll = () => {
if (window.innerWidth >= MOBILE_BREAKPOINT) {
navOffset = 0;
lastScrollY = window.scrollY;
scrollAccumulator = 0;
return;
}
const navHeight = headerElement?.offsetHeight ?? NAV_HEIGHT_FALLBACK;
const currentScrollY = window.scrollY;
const scrollDelta = currentScrollY - lastScrollY;
const directionChanged =
(scrollDelta > 0 && scrollAccumulator < 0) ||
(scrollDelta < 0 && scrollAccumulator > 0);
if (directionChanged) {
scrollAccumulator = 0;
}
scrollAccumulator += scrollDelta;
if (Math.abs(scrollAccumulator) >= SCROLL_THRESHOLD_PX) {
const overflow =
scrollAccumulator > 0
? scrollAccumulator - SCROLL_THRESHOLD_PX
: scrollAccumulator + SCROLL_THRESHOLD_PX;
navOffset = Math.max(
-navHeight,
Math.min(0, navOffset - overflow),
);
scrollAccumulator =
scrollAccumulator > 0 ? SCROLL_THRESHOLD_PX : -SCROLL_THRESHOLD_PX;
}
lastScrollY = currentScrollY;
};
const handleResize = () => {
if (window.innerWidth >= MOBILE_BREAKPOINT) {
navOffset = 0;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};
});
// the tablet-only sidenav modal closes on navigation
$effect(() => {
page.url.pathname;
sideNavModalOpen = false;
});
function formatRelativeDate(timestamp: number) {
const locale = getLocale();
const date = new Date(timestamp * 1000);
const timeFormatter = new Intl.DateTimeFormat(locale, {
hour: "numeric",
minute: "numeric",
});
const dateTimeFormatter = new Intl.DateTimeFormat(locale, {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const tomorrow = new Date(now);
tomorrow.setDate(now.getDate() + 1);
const isTomorrow = date.toDateString() === tomorrow.toDateString();
const formatRelativeDay = (daysFromToday: number) => {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const str = rtf.format(daysFromToday, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
};
if (isToday) return `${formatRelativeDay(0)}, ${timeFormatter.format(date)}`;
if (isTomorrow) {
return `${formatRelativeDay(1)}, ${timeFormatter.format(date)}`;
}
return dateTimeFormatter.format(date);
}
</script>
{#snippet siteLogoContent()}
<span class="siteLogoS">S</span>
<span class="siteLogoInk">ink</span>
{/snippet}
{#snippet siteTitle()}
<div class="siteTitle">
<a href="/" class="siteLogo">
{@render siteLogoContent()}
</a>
{#each breadcrumbs as crumb (crumb.href)}
<span class="separator">/</span>
{#if page.url.pathname === crumb.href}
{@render pageIcon(crumb)}
{:else}
<a href={crumb.href} class="breadcrumbLink">
{@render pageIcon(crumb)}
</a>
{/if}
{/each}
{#if currentPageText}
<span class="pageName">{currentPageText}</span>
{/if}
</div>
{/snippet}
{#snippet pageIcon(crumb: Breadcrumb)}
{#if crumb.type === "IMAGE" && crumb.imgPath}
<div class="pageIconWrapper">
<Image
path={crumb.imgPath}
alt=""
class="pageIcon rounded"
width={20}
height={20}
/>
</div>
{/if}
{/snippet}
{#snippet sideNavFooterContent()}
<SideNavFooter>
<SideNavUserPanel {notifications} {unseenIds} {showUnseenDot} />
</SideNavFooter>
{/snippet}
{#snippet sideNavChildren()}
<SideNavHeader>
{#snippet icon()}<Calendar />{/snippet}
{#snippet action()}
{#if user}
<a href={EVENTS_PAGE} class="viewAllLink">
{m.common_actions_viewAll()}
<ChevronRight size={14} />
</a>
{/if}
{/snippet}
{m.front_sideNav_myCalendar()}
</SideNavHeader>
{#each events as event (`${event.type}-${event.id}`)}
<ListLink
to={event.url}
imageUrl={event.logoUrl ?? undefined}
user={event.user ?? undefined}
>
{#snippet subtitle()}
{#if hydrated}
{formatRelativeDate(event.startsAt)}
{:else}
<span class="invisible">Placeholder</span>
{/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}
</ListLink>
{:else}
<div data-testid="side-nav-empty" class="sideNavEmpty">
{m.front_sideNav_noEvents()}
</div>
{/each}
<SideNavHeader>
{#snippet icon()}<Users />{/snippet}
{#snippet action()}
{#if user}
{#if unseenFriendRequests > 0}
<span
class="friendRequestsBadge"
role="status"
aria-label={unseenRequestsLabel(unseenFriendRequests)}
>
{unseenFriendRequests}
</span>
{/if}
<a href={FRIENDS_PAGE} class="viewAllLink">
{m.common_actions_viewAll()}
<ChevronRight size={14} />
</a>
{/if}
{/snippet}
{m.front_sideNav_friends()}
</SideNavHeader>
{#each friends.slice(0, MAX_DESKTOP_FRIENDS) as friend (friend.id)}
<FriendMenu {...friend} />
{:else}
<div data-testid="side-nav-empty" class="sideNavEmpty">
{user
? m.front_sideNav_friends_noFriends()
: m.front_sideNav_friends_notLoggedIn()}
</div>
{/each}
<SideNavHeader>
{#snippet icon()}<Tv />{/snippet}
{m.front_sideNav_streams()}
</SideNavHeader>
{#if streams.length === 0}
<div data-testid="side-nav-empty" class="sideNavEmpty">
{m.front_sideNav_noStreams()}
</div>
{/if}
<StreamListItems
{streams}
isLoggedIn={Boolean(user)}
savedTournamentIds={sidebarData?.savedTournamentIds}
/>
{/snippet}
<SideNav
collapsed={sideNavCollapsed}
topCentered={isFrontPage}
>
{#snippet top()}{@render siteTitle()}{/snippet}
{#snippet footer()}{@render sideNavFooterContent()}{/snippet}
{@render sideNavChildren()}
</SideNav>
<MobileNav
{sidebarData}
notifications={notifications ?? undefined}
unseenNotificationIds={unseenIds}
{showUnseenDot}
/>
<div class="container">
<header
bind:this={headerElement}
class="header"
style="transform: translateY({navOffset}px)"
>
<a href="/" class="siteLogo mobileLogo">
{@render siteLogoContent()}
</a>
<div class="sideNavCollapseButtonContainer" data-testid="sidenav-modal-trigger">
<button
type="button"
class="collapseButton sideNavModalTrigger"
onclick={() => {
sideNavModalOpen = !sideNavModalOpen;
}}
>
<PanelLeft />
</button>
{#if !sideNavModalOpen && showUnseenDot}
<NotificationDot />
{/if}
{#if !sideNavModalOpen && unseenFriendRequests > 0}
<span
class={[
"sideNavCollapseBadge",
{ sideNavCollapseBadgeLeft: showUnseenDot },
]}
role="status"
aria-label={unseenRequestsLabel(unseenFriendRequests)}
>
{unseenFriendRequests}
</span>
{/if}
</div>
<div
class="sideNavCollapseButtonContainer"
data-testid="sidenav-collapse-button"
>
<button
type="button"
class="collapseButton sideNavCollapseButton"
onclick={toggleSideNavCollapsed}
>
<PanelLeft />
</button>
{#if sideNavCollapsed && showUnseenDot}
<NotificationDot />
{/if}
{#if sideNavCollapsed && unseenFriendRequests > 0}
<span
class={[
"sideNavCollapseBadge",
{ sideNavCollapseBadgeLeft: showUnseenDot },
]}
role="status"
aria-label={unseenRequestsLabel(unseenFriendRequests)}
>
{unseenFriendRequests}
</span>
{/if}
</div>
<TopNavMenus />
<TopRightButtons
showSupport={Boolean(!user?.roles.includes("MINOR_SUPPORT"))}
showSearch={Boolean(user)}
isLoggedIn={Boolean(user)}
chatUnreadCount={0}
/>
<div id="nprogress-anchor" aria-hidden="true"></div>
</header>
{@render children()}
<Footer {patrons} gitCommit={GIT_COMMIT} />
</div>
{#if sideNavModalOpen}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="sideNavModalOverlay"
onclick={(event) => {
if (event.target === event.currentTarget) sideNavModalOpen = false;
}}
>
<div class="sideNavModal">
<SideNav class="sideNavInModal" topCentered={isFrontPage}>
{#snippet top()}{@render siteTitle()}{/snippet}
{#snippet footer()}{@render sideNavFooterContent()}{/snippet}
{@render sideNavChildren()}
</SideNav>
</div>
</div>
{/if}
<style>
.container {
flex: 1;
min-width: 0;
}
.header {
container-type: inline-size;
display: flex;
width: 100%;
height: var(--layout-nav-height);
align-items: center;
gap: var(--s-2);
border-bottom: 1.5px solid var(--color-border);
background-color: var(--color-bg-nav);
font-weight: var(--weight-extra);
padding-inline: var(--s-4);
position: sticky;
top: 0;
z-index: 10;
}
.siteTitle {
display: flex;
align-items: center;
gap: var(--s-2);
height: 100%;
min-width: 0;
padding: 3px;
margin: -3px;
}
.siteLogo {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 40px;
height: 36px;
background-color: var(--color-text-accent);
border-radius: var(--radius-field);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
text-decoration: none;
flex-shrink: 0;
transition: background-color 0.2s;
line-height: 1;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.siteLogoS {
font-size: 14px;
position: relative;
top: -4px;
}
.siteLogoInk {
font-size: 12px;
position: relative;
bottom: -4px;
}
.separator {
font-size: var(--font-xs);
color: var(--color-text-high);
font-weight: var(--weight-bold);
opacity: 0.5;
animation: fadeIn 200ms ease-out 150ms both;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 0.5;
}
}
.pageIconWrapper {
display: flex;
align-items: center;
flex-shrink: 0;
width: 28px;
height: 28px;
animation: fadeInFull 200ms ease-out 150ms both;
}
.pageIconWrapper :global(.pageIcon) {
width: 28px;
height: 28px;
object-fit: cover;
}
@keyframes fadeInFull {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.breadcrumbLink {
display: flex;
align-items: center;
text-decoration: none;
border-radius: var(--radius-field);
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.pageName {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
animation: fadeInFull 200ms ease-out 150ms both;
}
@media screen and (display-mode: standalone) {
.header {
align-items: flex-end;
padding-top: calc(var(--s-2) + env(safe-area-inset-top));
padding-bottom: var(--s-2);
height: calc(var(--layout-nav-height) + env(safe-area-inset-top));
}
.sideNavModalOverlay {
top: calc(var(--layout-nav-height) + env(safe-area-inset-top));
}
}
:global(.sideNavEmpty) {
font-size: var(--font-2xs);
color: var(--color-text-high);
padding: var(--s-1) var(--s-2);
margin: 0 auto;
font-style: italic;
}
:global(.viewAllLink) {
display: flex;
align-items: center;
gap: 2px;
}
:global(.viewAllLink svg) {
stroke-width: 3;
}
:global(.friendRequestsBadge) {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);
border-radius: 9px;
display: grid;
place-items: center;
margin-inline-end: var(--s-1-5);
pointer-events: none;
}
.sideNavCollapseBadge {
position: absolute;
top: -5px;
inset-inline-end: -5px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 16px;
height: 16px;
padding: 0 var(--s-0-5);
border-radius: 8px;
display: grid;
place-items: center;
line-height: 1;
pointer-events: none;
&.sideNavCollapseBadgeLeft {
inset-inline-end: auto;
inset-inline-start: -5px;
}
}
.sideNavCollapseButtonContainer {
display: none;
position: relative;
--dot-top: 2px;
--dot-right: 2px;
&:has(.sideNavCollapseButton) {
@media screen and (min-width: 1000px) {
display: flex;
}
}
&:has(.sideNavModalTrigger) {
@media screen and (min-width: 600px) and (max-width: 999px) {
display: flex;
}
}
}
.collapseButton {
display: none;
align-items: center;
justify-content: center;
appearance: none;
border: none;
background-color: transparent;
border-radius: var(--radius-field);
cursor: pointer;
padding: 0;
height: var(--field-size-sm);
aspect-ratio: 1 / 1;
&:focus-visible {
outline: var(--focus-ring);
}
& :global(svg) {
color: var(--color-text);
min-width: 20px;
max-width: 20px;
}
&.sideNavCollapseButton {
@media screen and (min-width: 1000px) {
display: flex;
}
}
&.sideNavModalTrigger {
@media screen and (min-width: 600px) and (max-width: 999px) {
display: flex;
}
}
}
.sideNavModalOverlay {
position: fixed;
inset: 0;
top: var(--layout-nav-height);
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 20;
}
.sideNavModal {
height: 100%;
width: var(--layout-sidenav-width);
border-right: 1.5px solid var(--color-border);
}
.sideNavModal :global(.sideNavInModal) {
display: flex;
position: static;
height: 100%;
min-width: unset;
max-width: unset;
border-right: none;
}
.mobileLogo {
display: flex;
@media screen and (min-width: 600px) {
display: none;
}
}
</style>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Avatar from "#lib/components/Avatar.svelte";
interface Props {
children: Snippet;
user?: {
discordId: string;
discordAvatar: string | null;
customAvatarUrl?: string | null;
};
subtitle?: string | null;
badge?: string | null;
badgeVariant?: "default" | "warning";
onclick?: () => void;
"aria-expanded"?: boolean;
"aria-haspopup"?: "menu" | "dialog";
}
let {
children,
user,
subtitle,
badge,
badgeVariant,
onclick,
"aria-expanded": ariaExpanded,
"aria-haspopup": ariaHaspopup,
}: Props = $props();
</script>
<button
type="button"
data-testid="list-button"
class="listButton"
{onclick}
aria-expanded={ariaExpanded}
aria-haspopup={ariaHaspopup}
>
{#if user}
<Avatar {user} size="xxsm" />
{/if}
<div class="listLinkContent">
<span class="listLinkTitle">{@render children()}</span>
{#if subtitle || badge}
<div class="listLinkSubtitleRow">
{#if subtitle}
<span data-testid="list-item-subtitle" class="listLinkSubtitle">
{subtitle}
</span>
{/if}
{#if badge}
<span
data-testid="list-item-badge"
class={["listLinkBadge", { listLinkBadgeWarning: badgeVariant === "warning" }]}
>
{badge}
</span>
{/if}
</div>
{/if}
</div>
</button>
<style>
.listButton {
font-size: var(--font-xs);
color: var(--color-text);
text-decoration: none;
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
transition:
background-color 0.15s,
color 0.15s;
display: flex;
align-items: center;
gap: var(--s-2);
background: none;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
&:hover {
&:not(:has(.listLinkSubtitle)) {
color: var(--color-text);
}
background-color: var(--color-bg-high);
}
&:focus-visible {
outline: var(--focus-ring);
}
}
.listLinkContent {
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--s-0-5);
width: 100%;
}
.listLinkTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.listLinkSubtitleRow {
display: flex;
align-items: center;
gap: var(--s-1-5);
width: 100%;
color: var(--color-text-high);
}
.listLinkSubtitle {
font-size: var(--font-2xs);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.listLinkBadge {
display: flex;
align-items: center;
margin-left: auto;
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
padding: 0 var(--s-1);
border-radius: var(--radius-selector);
height: var(--selector-size-xs);
text-align: center;
flex-shrink: 0;
text-transform: uppercase;
}
.listLinkBadgeWarning {
background-color: var(--color-text-second);
}
</style>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Avatar from "#lib/components/Avatar.svelte";
interface Props {
children: Snippet;
to: string;
onclick?: () => void;
isActive?: boolean;
imageUrl?: string;
overlayIconUrl?: string;
user?: {
discordId: string;
discordAvatar: string | null;
customAvatarUrl?: string | null;
};
subtitle?: string | Snippet;
badge?: string | Snippet;
badgeVariant?: "default" | "warning";
}
let {
children,
to,
onclick,
isActive,
imageUrl,
overlayIconUrl,
user,
subtitle,
badge,
badgeVariant,
}: Props = $props();
</script>
<a
href={to}
data-testid="list-link"
class="listLink"
{onclick}
aria-current={isActive ? "page" : undefined}
>
{#if user}
<Avatar {user} size="xxsm" />
{:else if imageUrl}
<div class="listLinkImageContainer">
<img src={imageUrl} alt="" class="listLinkImage" />
{#if overlayIconUrl}
<img src={overlayIconUrl} alt="" class="listLinkOverlayIcon" />
{/if}
</div>
{/if}
<div class="listLinkContent">
<span class="listLinkTitle">{@render children()}</span>
{#if subtitle || badge}
<div class="listLinkSubtitleRow">
{#if subtitle}
<span data-testid="list-item-subtitle" class="listLinkSubtitle">
{#if typeof subtitle === "string"}{subtitle}{:else}{@render subtitle()}{/if}
</span>
{/if}
{#if typeof badge === "string"}
<span
data-testid="list-item-badge"
class={["listLinkBadge", { listLinkBadgeWarning: badgeVariant === "warning" }]}
>
{badge}
</span>
{:else if badge}
{@render badge()}
{/if}
</div>
{/if}
</div>
</a>
<style>
.listLink {
font-size: var(--font-xs);
color: var(--color-text);
text-decoration: none;
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
transition:
background-color 0.15s,
color 0.15s;
display: flex;
align-items: center;
gap: var(--s-2);
&:hover {
&:not(:has(.listLinkSubtitle)) {
color: var(--color-text);
}
background-color: var(--color-bg-high);
}
&[aria-current="page"] {
color: var(--color-text);
background-color: var(--color-bg-higher);
font-weight: var(--weight-bold);
}
}
.listLinkImageContainer {
position: relative;
flex-shrink: 0;
}
.listLinkImage {
width: 32px;
height: 32px;
border-radius: var(--radius-avatar);
object-fit: cover;
flex-shrink: 0;
}
.listLinkOverlayIcon {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
border-radius: var(--radius-field);
background-color: var(--color-bg);
padding: 2px;
}
.listLinkContent {
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--s-0-5);
width: 100%;
}
.listLinkTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.listLinkSubtitleRow {
display: flex;
align-items: center;
gap: var(--s-1-5);
width: 100%;
color: var(--color-text-high);
}
.listLinkSubtitle {
font-size: var(--font-2xs);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.listLinkBadge {
display: flex;
align-items: center;
margin-left: auto;
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
padding: 0 var(--s-1);
border-radius: var(--radius-selector);
height: var(--selector-size-xs);
text-align: center;
flex-shrink: 0;
text-transform: uppercase;
}
.listLinkBadgeWarning {
background-color: var(--color-text-second);
}
</style>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { LOG_IN_URL } from "#lib/utils/urls.ts";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<form action={LOG_IN_URL} method="post" class="stack items-center">
{@render children()}
</form>

View File

@@ -0,0 +1,384 @@
<script lang="ts">
import {
Calendar,
ChevronRight,
LogIn,
Menu,
MessageSquare,
Settings,
User,
Users,
} from "@lucide/svelte";
import Avatar from "#lib/components/Avatar.svelte";
import NotificationContent from "#lib/features/notifications/components/NotificationContent.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { m } from "#lib/paraglide/messages.js";
import {
EVENTS_PAGE,
FRIENDS_PAGE,
SETTINGS_PAGE,
userPage,
} from "#lib/utils/urls.ts";
import EventsList from "./EventsList.svelte";
import FriendMenu from "./FriendMenu.svelte";
import type { NotificationRow, SidebarData } from "./layout-types.ts";
import LogInButtonContainer from "./LogInButtonContainer.svelte";
import MobileNavMenuOverlay from "./MobileNavMenuOverlay.svelte";
import MobilePanel from "./MobilePanel.svelte";
import MobileTab from "./MobileTab.svelte";
type PanelType = "closed" | "menu" | "friends" | "tourneys" | "chat" | "you";
const SENDOUQ_ACTIVITY_LABEL = "SendouQ";
interface Props {
sidebarData: SidebarData | undefined;
notifications?: NotificationRow[];
unseenNotificationIds?: number[];
showUnseenDot?: boolean;
}
let {
sidebarData,
notifications,
unseenNotificationIds,
showUnseenDot = false,
}: Props = $props();
let activePanel = $state<PanelType>("closed");
let previousPanel: PanelType = "closed";
const user = $derived(loggedInUser());
const hasFriendInSendouQ = $derived(
sidebarData?.friends.some(
(f) => f.subtitle === SENDOUQ_ACTIVITY_LABEL,
) ?? false,
);
const unseenFriendRequests = $derived(
sidebarData?.incomingFriendRequestIds.length ?? 0,
);
const skipAnimation = $derived(previousPanel !== "closed");
const loggedInTabs: PanelType[] = [
"menu",
"friends",
"tourneys",
"chat",
"you",
];
const loggedOutTabs: PanelType[] = ["menu"];
const ghostTabs = $derived(user ? loggedInTabs : loggedOutTabs);
function closePanel() {
previousPanel = activePanel;
activePanel = "closed";
}
function handleTabPress(panel: PanelType) {
// TODO(chat rebuild): the chat panel arrives with the scrims slice
if (panel === "chat") return;
previousPanel = activePanel;
activePanel = activePanel === panel ? "closed" : panel;
}
function handleGhostTabPress(index: number) {
const panel = ghostTabs[index];
if (panel) handleTabPress(panel);
}
</script>
<div class="mobileNav">
{#if activePanel === "menu"}
<MobileNavMenuOverlay
streams={sidebarData?.streams ?? []}
savedTournamentIds={sidebarData?.savedTournamentIds}
onClose={closePanel}
ghostTabCount={ghostTabs.length}
onGhostTabPress={handleGhostTabPress}
{skipAnimation}
/>
{/if}
{#if activePanel === "friends"}
<MobilePanel
title={m.front_sideNav_friends()}
onClose={closePanel}
ghostTabCount={ghostTabs.length}
onGhostTabPress={handleGhostTabPress}
{skipAnimation}
>
{#snippet icon()}<Users size={18} />{/snippet}
{#if (sidebarData?.friends.length ?? 0) > 0}
{#each sidebarData?.friends ?? [] as friend (friend.id)}
<FriendMenu {...friend} onNavigate={closePanel} />
{/each}
{:else}
<div data-testid="side-nav-empty" class="sideNavEmpty">
{user
? m.front_sideNav_friends_noFriends()
: m.front_sideNav_friends_notLoggedIn()}
</div>
{/if}
<a href={FRIENDS_PAGE} class="panelSectionLink" onclick={closePanel}>
{m.common_actions_viewAll()}
<ChevronRight size={14} />
</a>
</MobilePanel>
{/if}
{#if activePanel === "tourneys"}
<MobilePanel
title={m.front_sideNav_myCalendar()}
onClose={closePanel}
ghostTabCount={ghostTabs.length}
onGhostTabPress={handleGhostTabPress}
{skipAnimation}
>
{#snippet icon()}<Calendar size={18} />{/snippet}
<EventsList events={sidebarData?.events ?? []} onclick={closePanel} />
<a href={EVENTS_PAGE} class="panelSectionLink" onclick={closePanel}>
{m.common_actions_viewAll()}
<ChevronRight size={14} />
</a>
</MobilePanel>
{/if}
{#if activePanel === "you" && user}
<MobilePanel
title={m.front_mobileNav_you()}
onClose={closePanel}
ghostTabCount={ghostTabs.length}
onGhostTabPress={handleGhostTabPress}
{skipAnimation}
>
{#snippet icon()}<User size={18} />{/snippet}
<div class="youPanelUserRow">
<a href={userPage(user)} class="youPanelUser" onclick={closePanel}>
<Avatar {user} size="sm" />
<span data-testid="you-panel-username" class="youPanelUsername">
{user.username}
</span>
</a>
<a
href={SETTINGS_PAGE}
class="youPanelSettingsButton"
onclick={closePanel}
aria-label={m.common_pages_settings()}
>
<Settings size={18} />
</a>
</div>
{#if notifications}
<NotificationContent
{notifications}
unseenIds={unseenNotificationIds ?? []}
onClose={closePanel}
/>
{/if}
</MobilePanel>
{/if}
<nav class="tabBar">
<MobileTab
label={m.front_mobileNav_menu()}
isActive={activePanel === "menu"}
onPress={() => handleTabPress("menu")}
>
{#snippet icon()}<Menu />{/snippet}
</MobileTab>
{#if user}
<MobileTab
label={m.front_mobileNav_friends()}
isActive={activePanel === "friends"}
onPress={() => handleTabPress("friends")}
showNotificationDot={hasFriendInSendouQ}
badgeCount={unseenFriendRequests}
badgeLeft={hasFriendInSendouQ}
>
{#snippet icon()}<Users />{/snippet}
</MobileTab>
<MobileTab
label={m.front_sideNav_myCalendar()}
isActive={activePanel === "tourneys"}
onPress={() => handleTabPress("tourneys")}
>
{#snippet icon()}<Calendar />{/snippet}
</MobileTab>
<MobileTab
label={m.front_mobileNav_chat()}
isActive={activePanel === "chat"}
onPress={() => handleTabPress("chat")}
>
{#snippet icon()}<MessageSquare />{/snippet}
</MobileTab>
<MobileTab
label={m.front_mobileNav_you()}
isActive={activePanel === "you"}
onPress={() => handleTabPress("you")}
showNotificationDot={showUnseenDot}
>
{#snippet icon()}<User />{/snippet}
</MobileTab>
{:else}
<LogInButtonContainer>
<button type="submit" class="tab">
<span class="tabIcon">
<LogIn />
</span>
<span>{m.front_mobileNav_login()}</span>
</button>
</LogInButtonContainer>
{/if}
</nav>
</div>
<style>
.mobileNav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 19;
}
@media screen and (min-width: 600px) {
.mobileNav {
display: none;
}
}
.tabBar {
display: flex;
justify-content: space-around;
align-items: center;
height: calc(var(--layout-nav-height) + env(safe-area-inset-bottom));
background-color: var(--color-bg-nav);
border-top: 1.5px solid var(--color-border);
padding: 0 var(--s-4);
padding-bottom: env(safe-area-inset-bottom);
}
.tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
height: 100%;
aspect-ratio: 1 / 1;
background: none;
border: none;
border-radius: var(--radius-field);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
cursor: pointer;
text-decoration: none;
transition: color 0.15s;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: -1px;
}
&:hover {
color: var(--color-text-accent);
}
}
.tabIcon {
position: relative;
width: 24px;
height: 24px;
& :global(svg) {
width: 24px;
height: 24px;
}
}
.sideNavEmpty {
padding: var(--s-4);
text-align: center;
color: var(--color-text-high);
font-size: var(--font-xs);
}
.youPanelUserRow {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding: var(--s-1) var(--s-2);
}
.youPanelUser {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2);
text-decoration: none;
color: var(--color-text);
border-radius: var(--radius-field);
&:hover {
background-color: var(--color-bg-high);
}
}
.youPanelUsername {
font-size: var(--font-md);
font-weight: var(--weight-bold);
}
.youPanelSettingsButton {
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: var(--color-text-high);
border-radius: var(--radius-field);
height: var(--field-size);
aspect-ratio: 1 / 1;
&:hover {
background-color: var(--color-bg-high);
color: var(--color-text);
}
& :global(svg) {
width: 18px;
height: 18px;
}
}
.panelSectionLink {
display: flex;
align-items: center;
gap: 2px;
width: fit-content;
margin-top: auto;
margin-inline: auto;
margin-bottom: var(--s-4);
font-size: var(--font-2xs);
color: var(--color-text-high);
text-decoration: none;
height: var(--selector-size);
padding: 0 var(--s-3);
background-color: var(--color-bg-high);
border-radius: var(--radius-selector);
& :global(svg) {
stroke-width: 3;
}
&:hover {
color: var(--color-text);
background-color: var(--color-bg-higher);
}
}
</style>

View File

@@ -0,0 +1,335 @@
<script lang="ts">
import { Heart, Menu, Share2, Tv, X } from "@lucide/svelte";
import { page } from "$app/state";
import { browser } from "$app/env";
import Image from "#lib/components/Image.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { canAccessTrophies } from "#lib/features/trophies/trophies-utils.ts";
import { dynamicMessage } from "#lib/modules/i18n/messages.ts";
import { m } from "#lib/paraglide/messages.js";
import {
navIconUrl,
SENDOU_INK_BASE_URL,
SUPPORT_PAGE,
} from "#lib/utils/urls.ts";
import GhostTabBar from "./GhostTabBar.svelte";
import { navItems } from "./nav-items.ts";
import type { SidebarStream } from "./layout-types.ts";
import StreamListItems from "./StreamListItems.svelte";
interface Props {
streams: SidebarStream[];
savedTournamentIds?: number[];
onClose: () => void;
ghostTabCount: number;
onGhostTabPress: (index: number) => void;
skipAnimation: boolean;
}
let {
streams,
savedTournamentIds,
onClose,
ghostTabCount,
onGhostTabPress,
skipAnimation,
}: Props = $props();
const user = $derived(loggedInUser());
const visibleNavItems = $derived(
navItems.filter(
(item) => item.name !== "trophies" || canAccessTrophies(user),
),
);
function pageLabel(name: string) {
return dynamicMessage(`common_pages_${name.replaceAll("-", "_")}`);
}
function share() {
if (browser && typeof navigator.share === "function") {
void navigator.share({
url: `${SENDOU_INK_BASE_URL}${page.url.pathname}${page.url.search}`,
});
}
}
</script>
<div class={["panelOverlay", { noAnimation: skipAnimation }]}>
<div class={["menuOverlay", "scrollbar", { noAnimation: skipAnimation }]}>
<div data-testid="mobile-nav-panel" class="panelDialog" role="dialog">
<header class="menuHeader">
<div class="panelIconContainer">
<Menu size={18} />
</div>
<h2 class="panelTitle">{m.front_mobileNav_menu()}</h2>
<div class="menuHeaderActions">
{#if !user?.roles.includes("MINOR_SUPPORT")}
<a href={SUPPORT_PAGE} class="supportLinkButton">
<span class="supportLinkIcon"><Heart /></span>
{m.common_pages_support()}
</a>
{/if}
<button
type="button"
class="shareButton"
aria-label={m.common_actions_share()}
onclick={share}
>
<Share2 />
</button>
<button
type="button"
data-testid="panel-close-button"
class="panelCloseButton"
onclick={onClose}
>
<X size={18} />
</button>
</div>
</header>
<nav aria-label={m.front_mobileNav_menu()}>
<ul class="navGrid">
{#each visibleNavItems as item (item.name)}
<li>
<a href="/{item.url}" class="navItem" onclick={onClose}>
<div class="navItemImage">
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{pageLabel(item.name)}</span>
</a>
</li>
{/each}
</ul>
</nav>
<section>
<header class="menuHeader">
<div class="panelIconContainer">
<Tv size={18} />
</div>
<h3 class="panelTitle">{m.front_sideNav_streams()}</h3>
</header>
{#if streams.length === 0}
<div data-testid="side-nav-empty" class="sideNavEmpty">
{m.front_sideNav_noStreams()}
</div>
{/if}
<ul class="streamsList">
<StreamListItems
{streams}
onclick={onClose}
isLoggedIn={Boolean(user)}
{savedTournamentIds}
/>
</ul>
</section>
<GhostTabBar tabCount={ghostTabCount} onTabPress={onGhostTabPress} />
</div>
</div>
</div>
<style>
.panelOverlay {
position: fixed;
inset: 0;
bottom: var(--layout-nav-height);
z-index: 18;
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
animation: fade-in 200ms ease-out;
}
.menuOverlay {
position: fixed;
inset: 0;
z-index: 20;
background-color: var(--color-bg);
overflow-y: auto;
display: flex;
flex-direction: column;
animation: fade-in 200ms ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.noAnimation {
animation: none;
}
.panelDialog {
outline: none;
display: flex;
flex-direction: column;
height: 100%;
}
.menuHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-4);
background-color: var(--color-bg-high);
border-bottom: 1.5px solid var(--color-border);
border-top: 1.5px solid var(--color-border);
flex-shrink: 0;
color: var(--color-text-high);
min-height: var(--layout-nav-height);
.panelDialog > &:first-child {
border-top: none;
padding-block-start: env(safe-area-inset-top);
}
}
.menuHeaderActions {
display: flex;
align-items: center;
gap: var(--s-2);
margin-inline-start: auto;
}
.panelTitle {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
.panelIconContainer {
border-radius: var(--radius-field);
}
.panelCloseButton {
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-error);
padding: 0;
height: var(--field-size);
aspect-ratio: 1 / 1;
border-radius: var(--radius-field);
margin-inline-start: auto;
&:hover {
background-color: var(--color-bg-higher);
}
}
.supportLinkButton {
display: flex;
align-items: center;
justify-content: center;
border: var(--border-style-accent);
border-radius: var(--radius-field);
background-color: transparent;
color: var(--color-text-accent);
cursor: pointer;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
padding: 0 var(--field-padding);
height: var(--field-size-sm);
white-space: nowrap;
text-decoration: none;
}
.supportLinkIcon {
display: inline-flex;
min-width: 18px;
max-width: 18px;
margin-inline-end: var(--s-1);
& :global(svg) {
width: 100%;
height: auto;
}
}
.shareButton {
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-accent);
padding: 0;
width: var(--field-size-sm);
height: var(--field-size-sm);
border-radius: 50%;
& :global(svg) {
width: 18px;
height: 18px;
}
}
.sideNavEmpty {
padding: var(--s-4);
text-align: center;
color: var(--color-text-high);
font-size: var(--font-xs);
}
.streamsList {
list-style: none;
margin: 0;
padding: var(--s-4);
padding-block-end: calc(env(safe-area-inset-bottom) + var(--s-4));
& :global(a:not(:first-child)) {
padding-block: var(--s-2);
}
& :global(a:first-child) {
padding-block-start: 0;
}
}
.navGrid {
list-style: none;
margin: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--s-3);
padding: var(--s-4);
}
.navItem {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
text-decoration: none;
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-align: center;
&:hover {
color: var(--color-text-accent);
}
}
.navItemImage {
width: var(--field-size-lg);
aspect-ratio: 1 / 1;
border-radius: var(--radius-field);
background-color: var(--color-bg-higher);
display: grid;
place-items: center;
}
</style>

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import { X } from "@lucide/svelte";
import type { Snippet } from "svelte";
import GhostTabBar from "./GhostTabBar.svelte";
interface Props {
title: string;
icon: Snippet;
onClose: () => void;
children: Snippet;
ghostTabCount: number;
onGhostTabPress: (index: number) => void;
skipAnimation: boolean;
}
let {
title,
icon,
onClose,
children,
ghostTabCount,
onGhostTabPress,
skipAnimation,
}: Props = $props();
</script>
<div class={["panelOverlay", { noAnimation: skipAnimation }]}>
<div class={["panel", { noAnimation: skipAnimation }]}>
<div data-testid="mobile-nav-panel" class="panelDialog" role="dialog">
<header class="panelHeader">
<div class="panelIconContainer">{@render icon()}</div>
<h2 class="panelTitle">{title}</h2>
<button
type="button"
data-testid="panel-close-button"
class="panelCloseButton"
onclick={onClose}
>
<X size={18} />
</button>
</header>
<div class="panelContent scrollbar">
{@render children()}
</div>
<GhostTabBar tabCount={ghostTabCount} onTabPress={onGhostTabPress} />
</div>
</div>
</div>
<style>
.panelOverlay {
position: fixed;
inset: 0;
bottom: var(--layout-nav-height);
z-index: 18;
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
animation: fade-in 200ms ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 85%;
background-color: var(--color-bg);
border-radius: var(--radius-box) var(--radius-box) 0 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding-block-end: env(safe-area-inset-bottom);
animation: slide-up 200ms ease-out;
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.panelHeader {
position: sticky;
top: 0;
display: flex;
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-4);
background-color: var(--color-bg-high);
border-bottom: 1.5px solid var(--color-border);
z-index: 1;
flex-shrink: 0;
color: var(--color-text-high);
min-height: var(--layout-nav-height);
}
.panelTitle {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
.panelContent {
flex: 1;
overflow-y: auto;
padding: var(--s-2);
display: flex;
flex-direction: column;
}
.panelDialog {
outline: none;
display: flex;
flex-direction: column;
height: 100%;
}
.panelCloseButton {
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-error);
padding: 0;
height: var(--field-size);
aspect-ratio: 1 / 1;
border-radius: var(--radius-field);
margin-inline-start: auto;
&:hover {
background-color: var(--color-bg-higher);
}
}
.panelIconContainer {
border-radius: var(--radius-field);
}
.noAnimation {
animation: none;
}
</style>

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import type { Snippet } from "svelte";
import NotificationDot from "#lib/components/NotificationDot.svelte";
interface Props {
icon: Snippet;
label: string;
isActive: boolean;
onPress: () => void;
showNotificationDot?: boolean;
unreadCount?: number;
badgeCount?: number;
badgeLeft?: boolean;
}
let {
icon,
label,
isActive,
onPress,
showNotificationDot,
unreadCount,
badgeCount,
badgeLeft,
}: Props = $props();
const count = $derived(unreadCount ?? badgeCount);
</script>
<button type="button" class="tab" data-active={isActive} onclick={onPress}>
<span class="tabIcon">
{@render icon()}
{#if showNotificationDot}
<NotificationDot />
{/if}
{#if count}
<span class={["tabBadge", { tabBadgeLeft: badgeLeft }]}>
{count}
</span>
{/if}
</span>
<span>{label}</span>
</button>
<style>
.tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
height: 100%;
aspect-ratio: 1 / 1;
background: none;
border: none;
border-radius: var(--radius-field);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
cursor: pointer;
text-decoration: none;
transition: color 0.15s;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: -1px;
}
&:hover,
&[data-active="true"] {
color: var(--color-text-accent);
}
}
.tabIcon {
position: relative;
width: 24px;
height: 24px;
& :global(svg) {
width: 24px;
height: 24px;
}
}
.tabBadge {
position: absolute;
top: -4px;
right: -8px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 16px;
height: 16px;
padding: 0 var(--s-0-5);
border-radius: 8px;
display: grid;
place-items: center;
pointer-events: none;
line-height: 1;
&.tabBadgeLeft {
right: auto;
left: -8px;
}
}
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
class?: string;
footer?: Snippet;
top?: Snippet;
topCentered?: boolean;
collapsed?: boolean;
}
let {
children,
class: className,
footer,
top,
topCentered,
collapsed,
}: Props = $props();
</script>
<nav
data-testid="side-nav"
class={["sideNav", className, { sideNavCollapsed: collapsed }]}
>
<div class={["sideNavTop", { sideNavTopCentered: topCentered }]}>
{#if top}{@render top()}{/if}
</div>
<div class="sideNavInner scrollbar">
{@render children()}
</div>
{#if footer}{@render footer()}{/if}
</nav>
<style>
.sideNav {
background-color: var(--color-bg-nav);
min-width: var(--layout-sidenav-width);
max-width: var(--layout-sidenav-width);
border-right: 1.5px solid var(--color-border);
overflow: hidden;
position: sticky;
top: 0;
left: 0;
height: 100dvh;
display: none;
flex-direction: column;
@media screen and (min-width: 1000px) {
display: flex;
}
}
.sideNavCollapsed {
display: none;
}
.sideNavTop {
height: var(--layout-nav-height);
background-color: var(--color-bg-nav);
border-bottom: 1.5px solid var(--color-border);
display: flex;
align-items: center;
padding-inline: var(--s-2);
flex-shrink: 0;
}
.sideNavTopCentered {
justify-content: center;
}
.sideNavInner {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: var(--s-1-5);
padding-block-end: var(--s-2);
overflow-x: hidden;
overflow-y: auto;
flex: 1;
min-height: 0;
}
@media screen and (min-width: 1475px) {
:global(html[data-fuse="true"]) .sideNav {
z-index: 20;
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<div class="sideNavFooter">
{@render children()}
</div>
<style>
.sideNavFooter {
height: var(--layout-nav-height);
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1-5) var(--s-3);
background-color: var(--color-bg-nav);
border-top: 1.5px solid var(--color-border);
flex-shrink: 0;
}
.sideNavFooter :global(.sideNavFooterUser) {
display: flex;
align-items: center;
gap: var(--s-2);
color: var(--color-text);
text-decoration: none;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
border-radius: var(--radius-field);
padding: var(--s-1);
margin-left: calc(-1 * var(--s-1));
flex: 1;
min-width: 0;
}
.sideNavFooter :global(.sideNavFooterUser:hover) {
background-color: var(--color-bg-high);
}
.sideNavFooter :global(.sideNavFooterUsername) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sideNavFooter :global(.sideNavFooterActions) {
display: flex;
align-items: center;
gap: var(--s-0-5);
margin-left: auto;
}
.sideNavFooter :global(.sideNavFooterButton) {
display: grid;
place-items: center;
background-color: transparent;
width: var(--field-size-sm);
height: var(--field-size-sm);
padding: 0;
border: none;
border-radius: var(--radius-field);
color: var(--color-text-high);
cursor: pointer;
}
.sideNavFooter :global(.sideNavFooterButton:hover) {
background-color: var(--color-bg-high);
color: var(--color-text);
}
.sideNavFooter :global(.sideNavFooterButton:focus-visible) {
outline: var(--focus-ring);
outline-offset: -2px;
}
.sideNavFooter :global(.sideNavFooterButton svg) {
width: 18px;
height: 18px;
}
.sideNavFooter :global(.sideNavFooterNotification) {
position: relative;
}
.sideNavFooter :global(.sideNavFooterUnseenDot) {
--dot-top: 2px;
--dot-right: 2px;
}
</style>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
icon?: Snippet;
action?: Snippet;
}
let { children, icon, action }: Props = $props();
</script>
<header data-testid="side-nav-header" class="sideNavHeader">
{#if icon}
<div class="iconContainer">{@render icon()}</div>
{/if}
<h2>{@render children()}</h2>
{#if action}
<span class="sideNavHeaderAction">{@render action()}</span>
{/if}
</header>
<style>
.sideNavHeader {
color: var(--color-text-high);
padding: var(--s-1-5) var(--s-2);
margin-inline: calc(-1 * var(--s-1-5));
background-color: var(--color-bg-high);
display: flex;
align-items: center;
gap: var(--s-2);
border-color: var(--color-border);
border-top: 1.5px solid var(--color-border);
border-bottom: 1.5px solid var(--color-border);
height: var(--layout-nav-height);
&:first-child {
margin-block-start: calc(-1 * var(--s-1-5));
border-top: none;
}
& h2 {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
& :global(svg) {
width: 18px;
}
}
.sideNavHeaderAction {
display: flex;
align-items: center;
margin-inline-start: auto;
font-size: var(--font-2xs);
font-weight: var(--weight-normal);
& :global(a) {
color: var(--color-text-high);
text-decoration: none;
&:hover {
color: var(--color-text);
text-decoration: underline;
}
}
}
.iconContainer {
background-color: var(--color-bg-high);
border-radius: var(--radius-field);
padding: var(--s-1-5);
}
</style>

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import { Bell, LogIn, Settings } from "@lucide/svelte";
import { Popover } from "@sendou/components";
import { page } from "$app/state";
import Avatar from "#lib/components/Avatar.svelte";
import NotificationDot from "#lib/components/NotificationDot.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import NotificationContent from "#lib/features/notifications/components/NotificationContent.svelte";
import { m } from "#lib/paraglide/messages.js";
import { SETTINGS_PAGE, userPage } from "#lib/utils/urls.ts";
import type { NotificationRow } from "./layout-types.ts";
interface Props {
notifications: NotificationRow[] | null;
unseenIds: number[];
showUnseenDot: boolean;
}
let { notifications, unseenIds, showUnseenDot }: Props = $props();
const user = $derived(loggedInUser());
</script>
{#if user}
<a href={userPage(user)} class="sideNavFooterUser">
<Avatar {user} size="xs" />
<span class="sideNavFooterUsername">{user.username}</span>
</a>
<div class="sideNavFooterActions">
{#if notifications}
{#key page.url.pathname}
<div class="sideNavFooterNotification">
{#if showUnseenDot}
<NotificationDot
class="sideNavFooterUnseenDot"
testId="notifications-bell-dot"
/>
{/if}
<Popover
popoverClass={[
"notificationsPopoverContainer",
notifications.length === 0
? "notificationsPopoverEmpty"
: undefined,
]
.filter(Boolean)
.join(" ")}
>
{#snippet trigger(triggerProps)}
<button
type="button"
class="sideNavFooterButton"
data-testid="notifications-button"
aria-expanded={triggerProps["aria-expanded"]}
aria-haspopup={triggerProps["aria-haspopup"]}
onclick={triggerProps.onclick}
>
<Bell />
</button>
{/snippet}
<NotificationContent {notifications} {unseenIds} />
</Popover>
</div>
{/key}
{/if}
<a href={SETTINGS_PAGE} class="sideNavFooterButton">
<Settings />
</a>
</div>
{:else}
<form action="/auth" method="post">
<button type="submit" class="logInButton">
<span class="logInIcon"><LogIn /></span>
{m.common_header_login_discord()}
</button>
</form>
<div class="sideNavFooterActions">
<a href={SETTINGS_PAGE} class="sideNavFooterButton">
<Settings />
</a>
</div>
{/if}
<style>
:global(.notificationsPopoverContainer) {
min-width: 300px;
padding: 0;
background-color: var(--color-bg);
}
:global(.notificationsPopoverContainer svg) {
width: 16px;
}
:global(.notificationsPopoverEmpty) {
min-height: 200px;
}
.logInButton {
display: flex;
width: auto;
align-items: center;
justify-content: center;
border: var(--border-style-accent);
border-radius: var(--radius-field);
appearance: none;
background: var(--color-text-accent);
color: var(--color-text-inverse);
cursor: pointer;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
padding: 0 var(--field-padding);
user-select: none;
outline-color: var(--color-text-accent);
height: var(--field-size-sm);
white-space: nowrap;
&:focus-visible {
outline-style: solid;
outline-width: 2px;
outline-offset: 1px;
}
&:active {
transform: translateY(1px);
}
}
.logInIcon {
display: inline-flex;
min-width: 18px;
max-width: 18px;
margin-inline-end: var(--s-1);
}
.logInIcon :global(svg) {
width: 100%;
height: auto;
}
</style>

View File

@@ -0,0 +1,198 @@
<script lang="ts">
import { Bookmark, BookmarkCheck } from "@lucide/svelte";
import { isToday, isTomorrow } from "date-fns";
import Image from "#lib/components/Image.svelte";
import TierPill from "#lib/components/TierPill.svelte";
import { getLocale } from "#lib/paraglide/runtime.js";
import { m } from "#lib/paraglide/messages.js";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import { formatDistanceToNowLocalized } from "#lib/utils/format-distance.ts";
import { navIconUrl } from "#lib/utils/urls.ts";
import type { SidebarStream } from "./layout-types.ts";
import ListLink from "./ListLink.svelte";
interface Props {
streams: SidebarStream[];
onclick?: () => void;
isLoggedIn?: boolean;
savedTournamentIds?: number[];
}
let { streams, onclick, isLoggedIn, savedTournamentIds }: Props = $props();
const timeFormatter = $derived(
new Intl.DateTimeFormat(getLocale(), {
hour: "numeric",
minute: "numeric",
}),
);
const dateTimeFormatter = $derived(
new Intl.DateTimeFormat(getLocale(), {
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
}),
);
function formatRelativeDate(timestamp: number) {
const date = new Date(timestamp * 1000);
const timeStr = timeFormatter.format(date);
if (isToday(date) || isTomorrow(date)) {
const rtf = new Intl.RelativeTimeFormat(getLocale(), {
numeric: "auto",
});
const dayStr = rtf.format(isToday(date) ? 0 : 1, "day");
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
}
return dateTimeFormatter.format(date);
}
function isUpcoming(stream: SidebarStream) {
return databaseTimestampToDate(stream.startsAt).getTime() > Date.now();
}
function tournamentIdOf(stream: SidebarStream) {
return stream.id.startsWith("upcoming-")
? Number(stream.id.replace("upcoming-", ""))
: null;
}
</script>
{#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}
<span class="xpSubtitle">
<Image path={navIconUrl("xsearch")} alt="" class="xpIcon" />
{stream.peakXp}
</span>
{:else if stream.subtitle}
{stream.subtitle}
{:else if upcoming}
{formatRelativeDate(stream.startsAt)}
{:else}
{formatDistanceToNowLocalized(databaseTimestampToDate(stream.startsAt))}
{/if}
{/snippet}
{#snippet badge()}
<div class="badgeRow">
{#if isLoggedIn && tournamentId !== null}
{@const saved = savedTournamentIds?.includes(tournamentId) ?? false}
<!-- TODO(phase 3+): wire saving to the tournament save mutation once tournaments migrate -->
<button type="button" class="saveIconButton" title="Save">
{#if saved}
<BookmarkCheck size={14} data-testid="stream-saved-icon" />
{:else}
<Bookmark size={14} data-testid="stream-save-icon" />
{/if}
</button>
{/if}
{#if tier}
<div class="tierBadge">
<TierPill
{tier}
isTentative={!stream.tier && !!stream.tentativeTier}
/>
</div>
{/if}
</div>
{/snippet}
{#if showUpcomingDivider}
<div data-testid="upcoming-divider" class="upcomingDivider">
{m.front_sideNav_streams_upcoming()}
</div>
{/if}
{#if upcoming}
<ListLink
to={stream.url}
imageUrl={stream.imageUrl}
overlayIconUrl={stream.overlayIconUrl}
{subtitle}
{badge}
{onclick}
>
{stream.name}
</ListLink>
{:else}
<ListLink
to={stream.url}
imageUrl={stream.imageUrl}
overlayIconUrl={stream.overlayIconUrl}
{subtitle}
badge="LIVE"
badgeVariant="warning"
{onclick}
>
{stream.name}
</ListLink>
{/if}
{/each}
<style>
.xpSubtitle {
display: flex;
align-items: center;
gap: 2px;
}
.xpSubtitle :global(.xpIcon) {
width: 14px;
height: 14px;
}
.tierBadge {
flex-shrink: 0;
}
.upcomingDivider {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1) var(--s-2);
font-size: var(--font-3xs);
color: var(--color-text-high);
text-transform: uppercase;
letter-spacing: 0.05em;
&::before,
&::after {
content: "";
flex: 1;
border-top: 1px solid var(--color-border);
}
}
.badgeRow {
display: flex;
align-items: center;
gap: var(--s-1);
margin-left: auto;
flex-shrink: 0;
}
.saveIconButton {
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-high);
padding: 0;
height: var(--selector-size-sm);
aspect-ratio: 1 / 1;
border-radius: var(--radius-selector);
&:hover {
color: var(--color-text);
background-color: var(--color-bg-higher);
}
}
</style>

View File

@@ -0,0 +1,355 @@
<script lang="ts" module>
import { Config } from "#lib/config.ts";
const NAV_CATEGORIES = [
{
name: "play",
items: [
{ name: "sendouq", url: "q" },
{ name: "scrims", url: "scrims" },
{ name: "lfg", url: "lfg" },
{ name: "calendar", url: "calendar" },
{ name: "leaderboards", url: "leaderboards" },
...(Config.showLutiNavItem ? [{ name: "luti", url: "luti" }] : []),
],
},
{
name: "tools",
items: [
{ name: "analyzer", url: "analyzer" },
{ name: "comp-analyzer", url: "comp-analyzer" },
{ name: "object-damage-calculator", url: "object-damage-calculator" },
{ name: "plans", url: "plans" },
{ name: "maps", url: "maps" },
{ name: "tier-list-maker", url: "tier-list-maker" },
{ name: "xsearch", url: "xsearch" },
{ name: "admin", url: "admin", icon: "settings", staffOnly: true },
],
},
{
name: "community",
items: [
{ name: "builds", url: "builds" },
{ name: "art", url: "art" },
{ name: "articles", url: "a" },
{ name: "vods", url: "vods" },
{ name: "trophies", url: "trophies" },
{ name: "links", url: "links" },
{ name: "plus", url: "plus/suggestions" },
],
},
] as const;
interface NavCategoryItem {
name: string;
url: string;
icon?: string;
staffOnly?: boolean;
}
</script>
<script lang="ts">
import { page } from "$app/state";
import { dev } from "$app/env";
import { hasRole, loggedInUser } from "#lib/features/auth/user-state.ts";
import { canAccessTrophies } from "#lib/features/trophies/trophies-utils.ts";
import { dynamicMessage } from "#lib/modules/i18n/messages.ts";
import { m } from "#lib/paraglide/messages.js";
import { ADMIN_ID } from "#lib/features/admin/admin-constants.ts";
import {
impersonateUrl,
navIconUrl,
STOP_IMPERSONATING_URL,
} from "#lib/utils/urls.ts";
import Image from "#lib/components/Image.svelte";
const NZAP_TEST_ID = 2;
const DEV_IMPERSONATE_ITEMS = [
{ name: "Sendou", icon: "sendou_love", action: impersonateUrl(ADMIN_ID) },
{ name: "N-ZAP", icon: "u", action: impersonateUrl(NZAP_TEST_ID) },
{ name: "Logged out", icon: "log_in", action: STOP_IMPERSONATING_URL },
] as const;
const showStaffOnly = $derived(hasRole("STAFF") || dev);
const user = $derived(loggedInUser());
function categoryLabel(name: string) {
return dynamicMessage(`front_nav_${name}`);
}
function pageLabel(name: string) {
return dynamicMessage(`common_pages_${name.replaceAll("-", "_")}`);
}
function visibleItems(items: readonly NavCategoryItem[]) {
return items.filter((item) => {
if (item.staffOnly && !showStaffOnly) return false;
if (item.name === "trophies" && !canAccessTrophies(user)) return false;
return true;
});
}
let openCategory = $state<string | null>(null);
const returnTo = $derived(`${page.url.pathname}${page.url.search}`);
</script>
<svelte:window
onclick={() => {
if (openCategory !== null) openCategory = null;
}}
/>
<nav class="container">
{#each NAV_CATEGORIES as category (category.name)}
<div class="menuWrapper">
<button
type="button"
class="menuButton"
aria-expanded={openCategory === category.name}
aria-haspopup="dialog"
onclick={(event) => {
event.stopPropagation();
openCategory =
openCategory === category.name ? null : category.name;
}}
>
{categoryLabel(category.name)}
</button>
{#if openCategory === category.name}
<div class="menuPopover">
<div data-testid="menu-content" class="menuContent">
{#each visibleItems(category.items) as item (item.url)}
<a
href="/{item.url}"
class="menuItem"
onclick={() => {
openCategory = null;
}}
>
<Image
path={navIconUrl(item.icon ?? item.name)}
alt=""
size={20}
class="menuItemIcon"
/>
{pageLabel(item.name)}
</a>
{/each}
</div>
</div>
{:else}
<div class="preview">
{#each visibleItems(category.items) as item (item.url)}
<a
href="/{item.url}"
class="previewIcon"
title={pageLabel(item.name)}
aria-label={pageLabel(item.name)}
tabindex={-1}
>
<Image
path={navIconUrl(item.icon ?? item.name)}
alt=""
size={20}
/>
</a>
{/each}
</div>
{/if}
</div>
{/each}
{#if dev}
<div class="menuWrapper">
<button
type="button"
class="menuButton"
aria-expanded={openCategory === "dev"}
aria-haspopup="dialog"
onclick={(event) => {
event.stopPropagation();
openCategory = openCategory === "dev" ? null : "dev";
}}
>
Dev
</button>
{#if openCategory === "dev"}
<div class="menuPopover">
<div data-testid="menu-content" class="menuContent">
{#each DEV_IMPERSONATE_ITEMS as item (item.name)}
<form class="menuItemForm" method="post" action={item.action}>
<input type="hidden" name="returnTo" value={returnTo} />
<button type="submit" class="menuItem menuItemButton">
<Image
path={navIconUrl(item.icon)}
alt=""
size={20}
class="menuItemIcon"
/>
{item.name}
</button>
</form>
{/each}
</div>
</div>
{:else}
<div class="preview">
{#each DEV_IMPERSONATE_ITEMS as item (item.name)}
<form class="menuItemForm" method="post" action={item.action}>
<input type="hidden" name="returnTo" value={returnTo} />
<button
type="submit"
class="previewIcon previewIconButton"
title={item.name}
aria-label={item.name}
tabindex={-1}
>
<Image path={navIconUrl(item.icon)} alt="" size={20} />
</button>
</form>
{/each}
</div>
{/if}
</div>
{/if}
</nav>
<style>
.container {
display: none;
align-items: center;
@media screen and (min-width: 600px) {
display: flex;
}
}
.menuWrapper {
position: relative;
}
.menuButton {
display: flex;
align-items: center;
padding: 0 var(--s-2);
height: var(--field-size-sm);
border: none;
border-radius: var(--radius-field);
background: transparent;
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
cursor: pointer;
white-space: nowrap;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.menuPopover {
position: absolute;
top: calc(100% + var(--s-1));
left: 0;
z-index: 21;
max-width: min(20rem, calc(100vw - var(--s-4)));
padding: var(--s-2);
border: var(--border-style);
border-radius: var(--radius-box);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
background-color: var(--color-bg-high);
}
.menuContent {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-0-5);
}
.menuItem {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1-5) var(--s-2);
border-radius: var(--radius-field);
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-decoration: none;
transition:
background-color 0.15s,
color 0.15s;
white-space: nowrap;
&:hover,
&:focus-visible {
background-color: var(--color-bg-higher);
}
}
.menuItemForm {
display: contents;
}
.menuItemButton {
width: 100%;
border: none;
background: transparent;
font: inherit;
text-align: start;
cursor: pointer;
}
.menuItem :global(.menuItemIcon) {
flex-shrink: 0;
}
.preview {
position: absolute;
top: calc(100% + var(--s-1));
left: 0;
z-index: 1;
display: none;
width: max-content;
grid-auto-flow: column;
grid-template-rows: repeat(2, auto);
gap: var(--s-1);
padding: var(--s-1) var(--s-1-5);
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg-high);
.menuWrapper:hover & {
display: grid;
}
&::before {
content: "";
position: absolute;
top: calc(-1 * (var(--s-1) + var(--border-width)));
left: 0;
right: 0;
height: calc(var(--s-1) + var(--border-width));
}
}
.previewIcon {
display: flex;
flex-shrink: 0;
padding: var(--s-1);
border-radius: var(--radius-full);
transition: background-color 0.15s;
&:hover {
background-color: var(--color-bg-higher);
}
}
.previewIconButton {
border: none;
background: transparent;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,307 @@
<script lang="ts">
import { Heart, LogIn, MessageSquare, Search } from "@lucide/svelte";
import { Button } from "@sendou/components";
import { browser } from "$app/env";
import { m } from "#lib/paraglide/messages.js";
import { SUPPORT_PAGE } from "#lib/utils/urls.ts";
import AnythingAdder from "./AnythingAdder.svelte";
import LogInButtonContainer from "./LogInButtonContainer.svelte";
interface Props {
showSupport: boolean;
showSearch: boolean;
isLoggedIn: boolean;
onChatToggle?: () => void;
onChatModalToggle?: () => void;
chatUnreadCount?: number;
}
let {
showSupport,
showSearch,
isLoggedIn,
onChatToggle,
onChatModalToggle,
chatUnreadCount,
}: Props = $props();
const isMac = $derived(
browser && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent),
);
</script>
<div class="container">
{#if showSupport}
<div class="supportWrapper">
<a href={SUPPORT_PAGE} class="supportLinkButton">
<span class="supportLinkIcon"><Heart /></span>
{m.common_pages_support()}
</a>
</div>
<div class="supportWrapperCompact">
<a href={SUPPORT_PAGE} class="supportLinkButton square" aria-label={m.common_pages_support()}>
<span class="supportLinkIcon lonely"><Heart /></span>
</a>
</div>
{/if}
{#if isLoggedIn}
<div class="searchAndAddContainer">
{#if showSearch}
<div class="searchWrapper">
<!-- TODO(later slice): open the global search dialog once the search feature migrates -->
<button type="button" class="searchButton">
<Search class="searchIcon" />
<span class="searchPlaceholder">{m.common_search()}</span>
<kbd class="searchKbd">{isMac ? "Cmd+K" : "Ctrl+K"}</kbd>
</button>
</div>
{/if}
<div class="addNewWrapper">
<AnythingAdder />
</div>
<div class="addNewWrapperCompact">
<AnythingAdder compact />
</div>
</div>
{#if onChatToggle}
<div class="chatButtonWrapperPersistent">
<Button
shape="square"
size="small"
variant="outlined"
onclick={onChatToggle}
>
{#snippet icon()}<MessageSquare />{/snippet}
</Button>
{#if chatUnreadCount}
<span class="chatUnreadBadge">{chatUnreadCount}</span>
{/if}
</div>
{/if}
{#if onChatModalToggle}
<div class="chatButtonWrapperModal">
<Button
shape="square"
size="small"
variant="outlined"
onclick={onChatModalToggle}
>
{#snippet icon()}<MessageSquare />{/snippet}
</Button>
{#if chatUnreadCount}
<span class="chatUnreadBadge">{chatUnreadCount}</span>
{/if}
</div>
{/if}
{:else}
<LogInButtonContainer>
<Button type="submit" size="small">
{#snippet icon()}<LogIn />{/snippet}
{m.front_mobileNav_login()}
</Button>
</LogInButtonContainer>
{/if}
</div>
<style>
.container {
display: flex;
gap: var(--s-4);
margin-left: auto;
}
.supportWrapper {
@media screen and (max-width: 599px) {
display: none;
}
}
.supportWrapperCompact {
display: none;
}
.addNewWrapperCompact {
display: none;
}
@media screen and (min-width: 600px) {
@container (max-width: 600px) {
.supportWrapper {
display: none;
}
.supportWrapperCompact {
display: block;
}
}
@container (max-width: 475px) {
.addNewWrapper {
display: none;
}
.addNewWrapperCompact {
display: block;
}
.searchAndAddContainer {
grid-template-columns: 2fr 1fr;
}
}
}
.searchAndAddContainer {
display: grid;
grid-template-columns: 2fr max-content;
gap: var(--s-2);
}
.searchWrapper > button,
.addNewWrapper > :global(button) {
width: 100%;
}
.supportLinkButton {
display: flex;
width: auto;
align-items: center;
justify-content: center;
border: var(--border-style-accent);
border-radius: var(--radius-field);
appearance: none;
background-color: transparent;
color: var(--color-text-accent);
cursor: pointer;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
padding: 0 var(--field-padding);
user-select: none;
outline-color: var(--color-text-accent);
height: var(--field-size-sm);
white-space: nowrap;
text-decoration: none;
&:focus-visible {
outline-style: solid;
outline-width: 2px;
outline-offset: 1px;
}
&:active {
transform: translateY(1px);
}
&.square {
aspect-ratio: 1 / 1;
padding: 0;
}
}
.supportLinkIcon {
display: inline-flex;
min-width: 18px;
max-width: 18px;
margin-inline-end: var(--s-1);
&.lonely {
margin-inline-end: 0;
}
& :global(svg) {
width: 100%;
height: auto;
}
}
.searchButton {
display: flex;
align-items: center;
gap: var(--s-2);
padding: 0 var(--s-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-field);
background-color: var(--color-bg);
color: var(--color-text-high);
font-size: var(--font-xs);
cursor: pointer;
height: var(--field-size-sm);
transition: background-color 0.15s;
&:hover {
background-color: var(--color-bg-high);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.searchButton :global(.searchIcon) {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.searchPlaceholder {
flex-grow: 1;
text-align: left;
}
.searchKbd {
padding: 2px var(--s-1);
border-radius: var(--radius-field);
font-size: var(--font-2xs);
background-color: var(--color-bg-higher);
font-family: inherit;
}
.chatButtonWrapperPersistent {
display: none;
position: relative;
@media screen and (min-width: 1000px) {
display: block;
}
& > :global(button) {
width: 100%;
}
}
.chatButtonWrapperModal {
display: none;
position: relative;
@media screen and (min-width: 600px) and (max-width: 999px) {
display: block;
}
& > :global(button) {
width: 100%;
}
}
.chatUnreadBadge {
position: absolute;
top: -6px;
right: -6px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);
border-radius: 9px;
display: grid;
place-items: center;
pointer-events: none;
}
@media screen and (max-width: 1000px) {
.searchAndAddContainer {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -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<string, string | number>;
/** 0 = unseen, 1 = seen */
seen: number;
createdAt: number;
pictureUrl?: string | null;
href: string;
navIcon: string;
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
}>;
}

View File

@@ -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<string> | 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;
}

View File

@@ -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 [];
}

View File

@@ -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 extends SelectQueryBuilder<any, any, any>>(qb: QB) {
const nowTimestamp = dateToDatabaseTimestamp(new Date());
return (qb as SelectQueryBuilder<DB, keyof DB, Record<string, never>>)
.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<number>`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<number[]> {
const rows = await db
.selectFrom("FriendRequest")
.select("FriendRequest.id")
.where("FriendRequest.receiverId", "=", receiverId)
.execute();
return rows.map((row) => row.id);
}

View File

@@ -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<FriendActivityType, FriendActivityBadge | null> = {
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;
}

View File

@@ -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<number, string>();
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<number, string>;
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,
};
}

View File

@@ -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<ShowcaseCalendarEvent["id"]>;
organizers: Set<ShowcaseCalendarEvent["id"]>;
}
export async function upcomingTournaments(): Promise<ShowcaseCalendarEvent[]> {
const tournaments = await cachedTournaments();
return tournaments.upcoming;
}
export async function categorizedTournamentsByUserId(
userId: number | null,
): Promise<ShowcaseTournamentCollection> {
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<CommonUser["id"], ParticipationInfo> | 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<ParticipationInfo> {
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<Map<CommonUser["id"], ParticipationInfo>> {
const tournamentIds = tournaments.map((tournament) => tournament.id);
if (tournamentIds.length === 0) return new Map();
const tournamentsWithUsers =
await TournamentRepository.findRelatedUsersByTournamentIds(tournamentIds);
const result: Map<CommonUser["id"], ParticipationInfo> = 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<ShowcaseCalendarEvent> {
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;
}

View File

@@ -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();
});

View File

@@ -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<number>().as("peakXp"),
"LiveStream.viewerCount",
"LiveStream.thumbnailUrl",
"LiveStream.twitch as twitchUsername",
])
.execute();
}

View File

@@ -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<Notification & { id: number; createdAt: number; seen: number }>
>;
}

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { Bell, ChevronRight } from "@lucide/svelte";
import type { NotificationRow } from "#lib/components/layout/layout-types.ts";
import { m } from "#lib/paraglide/messages.js";
import { NOTIFICATIONS_URL } from "#lib/utils/urls.ts";
import NotificationItem from "./NotificationItem.svelte";
const PEEK_COUNT = 6;
interface Props {
notifications: NotificationRow[];
unseenIds: number[];
onClose?: () => void;
}
let { notifications, unseenIds, onClose }: Props = $props();
// unseen highlights stay visible while the popover is open even though the
// rows get marked seen server-side the moment it opens
// svelte-ignore state_referenced_locally -- capturing the initial value is the point
const stickyUnseenIds = new Set(unseenIds);
</script>
<h2 class="header">
<Bell />
{m.common_notifications_title()}
</h2>
<hr class="divider" />
{#if notifications.length === 0}
<div class="noNotifications">
{m.common_notifications_empty()}
</div>
{:else}
<div>
{#each notifications as notification, i (notification.id)}
<NotificationItem
{notification}
unseen={stickyUnseenIds.has(notification.id)}
{onClose}
/>
{#if i !== notifications.length - 1}
<hr class="itemDivider" />
{/if}
{/each}
</div>
{/if}
{#if notifications.length === PEEK_COUNT}
<div>
<hr class="divider" />
<a
href={NOTIFICATIONS_URL}
class="viewAllLink"
data-testid="notifications-see-all-button"
onclick={onClose}
>
{m.common_actions_viewAll()}
<ChevronRight size={14} />
</a>
</div>
{/if}
<style>
.header {
display: flex;
align-items: center;
font-size: var(--font-sm);
gap: var(--s-2);
padding: var(--s-1) var(--s-2);
& :global(svg) {
min-width: 18px;
min-height: 18px;
max-width: 18px;
max-height: 18px;
}
}
.noNotifications {
display: grid;
place-items: center;
font-weight: var(--weight-semi);
color: var(--color-text-high);
margin-block-start: 65px;
}
.divider {
border-color: var(--color-border);
}
.itemDivider {
margin-inline: var(--s-3);
border-width: 0.5px;
border-color: var(--color-bg-high);
}
.viewAllLink {
display: flex;
align-items: center;
gap: 2px;
width: fit-content;
margin: var(--s-2) auto;
font-size: var(--font-2xs);
color: var(--color-text-high);
text-decoration: none;
padding: 0 var(--s-3);
height: var(--selector-size);
background-color: var(--color-bg-high);
border-radius: var(--radius-field);
& :global(svg) {
stroke-width: 3;
}
&:hover {
color: var(--color-text);
background-color: var(--color-bg-higher);
}
}
@media screen and (max-width: 599px) {
.viewAllLink {
margin-top: var(--s-4);
}
}
</style>

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import type { NotificationRow } from "#lib/components/layout/layout-types.ts";
import Image from "#lib/components/Image.svelte";
import { dynamicMessage } from "#lib/modules/i18n/messages.ts";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import { formatDistanceToNowLocalized } from "#lib/utils/format-distance.ts";
import { navIconUrl } from "#lib/utils/urls.ts";
interface Props {
notification: NotificationRow;
unseen: boolean;
onClose?: () => void;
}
let { notification, unseen, onClose }: Props = $props();
const text = $derived.by(() => {
let message = dynamicMessage(
`common_notifications_text_${notification.type}`,
);
for (const [key, value] of Object.entries(notification.meta ?? {})) {
message = message.replaceAll(`{${key}}`, String(value));
}
return message;
});
</script>
<a
href={notification.href}
class="item"
data-testid="notification-item"
onclick={onClose}
>
<div class="imageContainer">
{#if unseen}
<div class="unseenDot" data-testid="notification-unseen-dot"></div>
{/if}
{#if notification.pictureUrl}
<img
src={notification.pictureUrl}
alt="Notification"
class="itemImage"
width={124}
height={124}
/>
{:else}
<Image
path={navIconUrl(notification.navIcon)}
width={24}
height={24}
alt=""
/>
{/if}
</div>
<div class="itemHeader">{text}</div>
<div class="timestamp">
{formatDistanceToNowLocalized(
databaseTimestampToDate(notification.createdAt),
)}
</div>
</a>
<style>
.imageContainer {
place-self: center;
grid-area: image;
border-radius: var(--radius-avatar);
width: 30px;
height: 30px;
background-color: var(--color-bg-high);
display: grid;
place-items: center;
position: relative;
}
.item {
padding: var(--s-1) var(--s-3);
display: grid;
grid-template-areas: "image header" "image timestamp";
grid-template-columns: 30px 1fr;
column-gap: var(--s-2);
padding-block: var(--s-3);
color: var(--color-text);
&:hover .imageContainer {
outline: 3px solid var(--color-bg-higher);
}
&:focus-within .imageContainer {
outline: 3px solid var(--color-accent-low);
}
}
.unseenDot {
background-color: var(--color-text-accent);
border-radius: 100%;
width: 8px;
height: 8px;
position: absolute;
top: -1px;
left: -1px;
outline: 2px solid var(--color-bg);
}
.itemImage {
width: 30px;
height: 30px;
border-radius: var(--radius-avatar);
}
.itemHeader {
grid-area: header;
font-size: var(--font-sm);
font-weight: var(--weight-semi);
}
.timestamp {
grid-area: timestamp;
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-body);
}
</style>

View File

@@ -0,0 +1,4 @@
export const NOTIFICATIONS = {
PEEK_COUNT: 6,
MAX_SHOWN: 100,
};

View File

@@ -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<string, number | string> | undefined = undefined,
> = M extends undefined
? { type: T; pictureUrl?: string }
: { type: T; meta: M; pictureUrl?: string };

View File

@@ -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<string, string | number>
| undefined,
seen: notification.seen,
createdAt: notification.createdAt,
pictureUrl: notification.pictureUrl,
href: notificationLink(notification),
navIcon: notificationNavIcon(notification.type),
}));
}

View File

@@ -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,
};
});

View File

@@ -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;
};

View File

@@ -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<typeof findManyForRowType>;
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<SidebarScrim[]> {
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,
};
});
}

View File

@@ -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<ScrimPostUser>;
chatCode: string | null;
requests: Array<ScrimPostRequest>;
/** 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<ScrimPostUser>;
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;
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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<LiteGroup[]> {
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<number, LiteGroup>();
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;
}

View File

@@ -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;
}

View File

@@ -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<number>();
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<SidebarStream[]> {
return cachified({
key: COMBINED_STREAMS_KEY,
cache,
ttl: ttl(10 * 60 * 1000),
async getFreshValue() {
return combinedStreams();
},
});
}
async function combinedStreams(): Promise<SidebarStream[]> {
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<number, (typeof xRankRows)[number]>();
for (const row of xRankRows) {
const existing = xRankByUser.get(row.id);
if (!existing || (row.peakXp ?? 0) > (existing.peakXp ?? 0)) {
xRankByUser.set(row.id, row);
}
}
for (const row of xRankByUser.values()) {
if (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<typeof FriendRepository.findByUserIdWithActivity>
>[number];
function resolveFriends(
friendsWithActivity: FriendWithActivity[],
streamedSendouQMatches: ReadonlyMap<number, string>,
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,
};
}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import type { Theme } from "./theme.server.ts";
interface Props {
/** The user's explicitly chosen theme from the theme cookie, `null` when following the system theme. */
theme: Theme | null;
}
let { theme }: Props = $props();
const metaColorScheme = $derived(
theme === "light" ? "light dark" : "dark light",
);
// applied before first paint when no explicit theme is set, so the page
// never flashes the wrong theme; the html class is otherwise SSR'd
const CLIENT_THEME_SCRIPT = `
;(() => {
const theme = window.matchMedia("(prefers-color-scheme: light)").matches
? 'light'
: 'dark';
const cl = document.documentElement.classList;
const themeAlreadyApplied = cl.contains('light') || cl.contains('dark');
if (!themeAlreadyApplied) {
cl.add(theme);
}
const meta = document.querySelector('meta[name=color-scheme]');
if (meta) {
meta.content = theme === 'dark' ? 'dark light' : 'light dark';
}
})();
`;
</script>
<svelte:head>
<meta name="color-scheme" content={metaColorScheme} />
{#if theme === null}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<script>${CLIENT_THEME_SCRIPT}</script>`}
{/if}
</svelte:head>

View File

@@ -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<number, SeriesMatch[]> | null = null;
async function loadCache(): Promise<Map<number, SeriesMatch[]>> {
const rows = await findAllSeriesWithTierHistory();
const result = new Map<number, SeriesMatch[]>();
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<number | null> {
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<void> {
cache = await loadCache();
}

View File

@@ -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<number[]> {
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<ShowcaseCalendarEvent[]> {
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<number[]> {
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!);
}

View File

@@ -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<typeof findAllForShowcase>;
/** 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<number>().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();
}

View File

@@ -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;
}

View File

@@ -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];
}

View File

@@ -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<Role> } | null) {
if (TROPHIES_RELEASED) return true;
if (!user) return false;
return user.roles.includes("ADMIN") || user.roles.includes("QA");
}

View File

@@ -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

View File

@@ -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<DBBoolean>;
languages: JSONColumnTypeNullable<UnifiedLanguageCode[]>;
patronTier: number | null;
patronStartedAt: number | null;
preferences: JSONColumnTypeNullable<UserPreferences>;
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<TournamentSettingsLite>;
id: GeneratedAlways<number>;
/** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */
isFinalized: Generated<DBBoolean>;
/** 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<number>;
discordUrl: GeneratedAlways<string | null>;
name: string;
participantCount: number | null;
hidden: Generated<DBBoolean>;
tournamentId: number | null;
organizationId: number | null;
avatarImgId: number | null;
trophyId: number | null;
}
export interface CalendarEventDate {
eventId: number;
id: GeneratedAlways<number>;
startsAt: number;
}
export interface SavedCalendarEvent {
id: GeneratedAlways<number>;
userId: number;
calendarEventId: number;
createdAt: Generated<number>;
}
export interface TournamentStaff {
tournamentId: number;
userId: number;
role: TournamentStaffRole;
}
export interface TournamentTeam {
createdAt: Generated<number>;
id: GeneratedAlways<number>;
inviteCode: string;
name: string;
prefersNotToHost: Generated<DBBoolean>;
droppedOut: Generated<DBBoolean>;
seed: number | null;
/** For formats that have many starting brackets, where should the team start? */
startingBracketIdx: number | null;
activeRosterUserIds: JSONColumnTypeNullable<number[]>;
tournamentId: number;
teamId: number | null;
avatarImgId: number | null;
isLooking: Generated<DBBoolean>;
isPlaceholder: Generated<DBBoolean>;
lfgNote: string | null;
chatCode: Generated<string | null>;
/** 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<DBBoolean>;
}
export interface TournamentTeamMember {
createdAt: Generated<number>;
inGameName: string | null;
tournamentTeamId: number;
userId: number;
role: Generated<"OWNER" | "MANAGER" | "REGULAR">;
isStayAsSub: Generated<DBBoolean>;
/** Set when the member was added to the roster after registration closed. */
isSub: Generated<DBBoolean>;
/** Set when the member was added to the roster by the tournament organizer instead of joining on their own. */
isOrganizerAdded: Generated<DBBoolean>;
// denormalized from TournamentTeam.isLooking
isLooking: Generated<DBBoolean>;
}
export interface TournamentOrganization {
id: GeneratedAlways<number>;
name: string;
slug: string;
description: string | null;
socials: JSONColumnTypeNullable<string[]>;
avatarImgId: number | null;
isEstablished: Generated<DBBoolean>;
}
export interface TournamentOrganizationSeries {
id: GeneratedAlways<number>;
organizationId: number;
name: string;
description: string | null;
substringMatches: JSONColumnType<string[]>;
showLeaderboard: Generated<DBBoolean>;
tierHistory: JSONColumnTypeNullable<TournamentTierNumber[]>;
}
export interface TournamentMatchVod {
id: GeneratedAlways<number>;
matchId: number;
userId: number | null;
platform: "TWITCH";
account: string;
platformVideoId: string;
timestampSeconds: number;
viewCount: number;
}
export interface Friendship {
id: GeneratedAlways<number>;
userOneId: number;
userTwoId: number;
createdAt: Generated<number>;
}
/** Pending friend request from one user to another. */
export interface FriendRequest {
id: GeneratedAlways<number>;
senderId: number;
receiverId: number;
createdAt: Generated<number>;
}
export interface Group {
chatCode: string | null;
createdAt: Generated<number>;
id: GeneratedAlways<number>;
inviteCode: string;
latestActionAt: Generated<number>;
/** If truthy, group was at least partly made in the matchmaking UI (/q/looking) */
matchmade: Generated<DBBoolean>;
status: "PREPARING" | "ACTIVE" | "INACTIVE" | "READY_CHECK";
teamId: number | null;
}
export interface LiveStream {
id: GeneratedAlways<number>;
userId: number | null;
viewerCount: number;
thumbnailUrl: string;
twitch: string | null;
}
export interface ExternalStream {
id: GeneratedAlways<number>;
name: string;
url: string;
avatarImgId: number | null;
startsAt: number;
createdAt: Generated<number>;
}
export interface Notification {
id: GeneratedAlways<number>;
type: NotificationValue["type"];
meta: JSONColumnTypeNullable<Record<string, number | string>>;
pictureUrl: string | null;
createdAt: Generated<number>;
}
export interface NotificationUser {
notificationId: number;
userId: number;
seen: Generated<DBBoolean>;
}
export interface ScrimPost {
id: GeneratedAlways<number>;
/** 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<AssociationVisibility>;
/** 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<DBBoolean>;
/** 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<number>;
updatedAt: Generated<number>;
}
export interface ScrimPostUser {
scrimPostId: number;
userId: number;
/** User is the author of the post */
isOwner: DBBoolean;
}
export interface ScrimPostRequest {
id: GeneratedAlways<number>;
scrimPostId: number;
teamId: number | null;
message: string | null;
/** Specific time selected by requester (required when post has rangeEndsAt) */
startsAt: number | null;
isAccepted: Generated<DBBoolean>;
createdAt: Generated<number>;
}
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;

View File

@@ -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<T extends number | null = number | null>() {
return sql<T>`"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<Tables, "CalendarEvent">,
) {
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<DB, "Tournament">) {
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<DB, "Tournament">) {
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<DB, "Tournament">) {
return tournamentCheckedInTeams(eb).select(({ fn }) => [
fn.count<number>("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<DB, "Tournament">,
) {
return eb
.case()
.when("Tournament.isFinalized", "=", 1)
.then(
eb
.selectFrom("TournamentResult")
.whereRef("TournamentResult.tournamentId", "=", "Tournament.id")
.select(({ fn }) => [
fn.count<number>("TournamentResult.userId").distinct().as("count"),
]),
)
.else(
tournamentCheckedInTeams(eb)
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.select(({ fn }) => [
fn
.count<number>("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<string>`coalesce(${sql.ref(`${alias}.tournamentName`)}, ${sql.ref(

View File

@@ -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 });
}

View File

@@ -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__;

View File

@@ -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`;

View File

@@ -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),
};
};

View File

@@ -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();
}
});
</script>
{@render children()}
<ThemeHead {theme} />
<PwaLinks />
{#if IS_E2E_TEST_RUN}
<HydrationTestIndicator />
{/if}
<Layout>
{@render children()}
</Layout>

View File

@@ -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,
},
],
};
};

View File

@@ -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"
}
]
}
]
}

BIN
apps/web/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,2 @@
User-agent: *
Allow: /

View File

@@ -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",

View File

@@ -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}
>

View File

@@ -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,

View File

@@ -570,11 +570,51 @@ function extractHead(html: string): string {
return match?.[1] ?? "";
}
/** `<link rel>` 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 `<head>` 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+</g, ">\n<")
.replace(/[ \t]+/g, " ")
.trim();
const withoutComments = normalizeOrigins(head, origins).replace(
/<!--[\s\S]*?-->/g,
"",
);
const tags: string[] = [];
const titleMatch = withoutComments.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (titleMatch) {
tags.push(`<title>${titleMatch[1].trim()}</title>`);
}
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 {

View File

@@ -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",