mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
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:
58
apps/web/src/lib/components/NotificationDot.svelte
Normal file
58
apps/web/src/lib/components/NotificationDot.svelte
Normal 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>
|
||||
20
apps/web/src/lib/components/PwaLinks.svelte
Normal file
20
apps/web/src/lib/components/PwaLinks.svelte
Normal 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>
|
||||
191
apps/web/src/lib/components/TierPill.svelte
Normal file
191
apps/web/src/lib/components/TierPill.svelte
Normal 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>
|
||||
16
apps/web/src/lib/components/icons/DiscordIcon.svelte
Normal file
16
apps/web/src/lib/components/icons/DiscordIcon.svelte
Normal 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>
|
||||
17
apps/web/src/lib/components/icons/GitHubIcon.svelte
Normal file
17
apps/web/src/lib/components/icons/GitHubIcon.svelte
Normal 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>
|
||||
17
apps/web/src/lib/components/icons/PatreonIcon.svelte
Normal file
17
apps/web/src/lib/components/icons/PatreonIcon.svelte
Normal 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>
|
||||
16
apps/web/src/lib/components/icons/TwitchIcon.svelte
Normal file
16
apps/web/src/lib/components/icons/TwitchIcon.svelte
Normal 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>
|
||||
67
apps/web/src/lib/components/layout/AnythingAdder.svelte
Normal file
67
apps/web/src/lib/components/layout/AnythingAdder.svelte
Normal 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}
|
||||
99
apps/web/src/lib/components/layout/EventsList.svelte
Normal file
99
apps/web/src/lib/components/layout/EventsList.svelte
Normal 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>
|
||||
258
apps/web/src/lib/components/layout/Footer.svelte
Normal file
258
apps/web/src/lib/components/layout/Footer.svelte
Normal 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>
|
||||
148
apps/web/src/lib/components/layout/FriendMenu.svelte
Normal file
148
apps/web/src/lib/components/layout/FriendMenu.svelte
Normal 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>
|
||||
46
apps/web/src/lib/components/layout/GhostTabBar.svelte
Normal file
46
apps/web/src/lib/components/layout/GhostTabBar.svelte
Normal 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>
|
||||
746
apps/web/src/lib/components/layout/Layout.svelte
Normal file
746
apps/web/src/lib/components/layout/Layout.svelte
Normal 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>
|
||||
145
apps/web/src/lib/components/layout/ListButton.svelte
Normal file
145
apps/web/src/lib/components/layout/ListButton.svelte
Normal 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>
|
||||
178
apps/web/src/lib/components/layout/ListLink.svelte
Normal file
178
apps/web/src/lib/components/layout/ListLink.svelte
Normal 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>
|
||||
@@ -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>
|
||||
384
apps/web/src/lib/components/layout/MobileNav.svelte
Normal file
384
apps/web/src/lib/components/layout/MobileNav.svelte
Normal 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>
|
||||
335
apps/web/src/lib/components/layout/MobileNavMenuOverlay.svelte
Normal file
335
apps/web/src/lib/components/layout/MobileNavMenuOverlay.svelte
Normal 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>
|
||||
154
apps/web/src/lib/components/layout/MobilePanel.svelte
Normal file
154
apps/web/src/lib/components/layout/MobilePanel.svelte
Normal 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>
|
||||
108
apps/web/src/lib/components/layout/MobileTab.svelte
Normal file
108
apps/web/src/lib/components/layout/MobileTab.svelte
Normal 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>
|
||||
90
apps/web/src/lib/components/layout/SideNav.svelte
Normal file
90
apps/web/src/lib/components/layout/SideNav.svelte
Normal 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>
|
||||
95
apps/web/src/lib/components/layout/SideNavFooter.svelte
Normal file
95
apps/web/src/lib/components/layout/SideNavFooter.svelte
Normal 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>
|
||||
75
apps/web/src/lib/components/layout/SideNavHeader.svelte
Normal file
75
apps/web/src/lib/components/layout/SideNavHeader.svelte
Normal 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>
|
||||
140
apps/web/src/lib/components/layout/SideNavUserPanel.svelte
Normal file
140
apps/web/src/lib/components/layout/SideNavUserPanel.svelte
Normal 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>
|
||||
198
apps/web/src/lib/components/layout/StreamListItems.svelte
Normal file
198
apps/web/src/lib/components/layout/StreamListItems.svelte
Normal 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>
|
||||
355
apps/web/src/lib/components/layout/TopNavMenus.svelte
Normal file
355
apps/web/src/lib/components/layout/TopNavMenus.svelte
Normal 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>
|
||||
307
apps/web/src/lib/components/layout/TopRightButtons.svelte
Normal file
307
apps/web/src/lib/components/layout/TopRightButtons.svelte
Normal 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>
|
||||
84
apps/web/src/lib/components/layout/layout-types.ts
Normal file
84
apps/web/src/lib/components/layout/layout-types.ts
Normal 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;
|
||||
}
|
||||
111
apps/web/src/lib/components/layout/nav-items.ts
Normal file
111
apps/web/src/lib/components/layout/nav-items.ts
Normal 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);
|
||||
142
apps/web/src/lib/components/pwa-splash-screens.ts
Normal file
142
apps/web/src/lib/components/pwa-splash-screens.ts
Normal 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;
|
||||
23
apps/web/src/lib/features/associations/associations-types.ts
Normal file
23
apps/web/src/lib/features/associations/associations-types.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
44
apps/web/src/lib/features/calendar/calendar-types.ts
Normal file
44
apps/web/src/lib/features/calendar/calendar-types.ts
Normal 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;
|
||||
}
|
||||
39
apps/web/src/lib/features/core-streams/streams.server.ts
Normal file
39
apps/web/src/lib/features/core-streams/streams.server.ts
Normal 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 [];
|
||||
}
|
||||
130
apps/web/src/lib/features/friends/FriendRepository.server.ts
Normal file
130
apps/web/src/lib/features/friends/FriendRepository.server.ts
Normal 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);
|
||||
}
|
||||
40
apps/web/src/lib/features/friends/friends-constants.ts
Normal file
40
apps/web/src/lib/features/friends/friends-constants.ts
Normal 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;
|
||||
}
|
||||
116
apps/web/src/lib/features/friends/friends-utils.server.ts
Normal file
116
apps/web/src/lib/features/friends/friends-utils.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 }>
|
||||
>;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
export const NOTIFICATIONS = {
|
||||
PEEK_COUNT: 6,
|
||||
MAX_SHOWN: 100,
|
||||
};
|
||||
115
apps/web/src/lib/features/notifications/notifications-types.ts
Normal file
115
apps/web/src/lib/features/notifications/notifications-types.ts
Normal 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 };
|
||||
126
apps/web/src/lib/features/notifications/notifications-utils.ts
Normal file
126
apps/web/src/lib/features/notifications/notifications-utils.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
32
apps/web/src/lib/features/scrims/Scrim.ts
Normal file
32
apps/web/src/lib/features/scrims/Scrim.ts
Normal 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;
|
||||
};
|
||||
312
apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts
Normal file
312
apps/web/src/lib/features/scrims/ScrimPostRepository.server.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
84
apps/web/src/lib/features/scrims/scrims-types.ts
Normal file
84
apps/web/src/lib/features/scrims/scrims-types.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
158
apps/web/src/lib/features/sendouq-streams/streams.server.ts
Normal file
158
apps/web/src/lib/features/sendouq-streams/streams.server.ts
Normal 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);
|
||||
}
|
||||
87
apps/web/src/lib/features/sendouq/q-groups.server.ts
Normal file
87
apps/web/src/lib/features/sendouq/q-groups.server.ts
Normal 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;
|
||||
}
|
||||
88
apps/web/src/lib/features/sidebar/StreamRanking.ts
Normal file
88
apps/web/src/lib/features/sidebar/StreamRanking.ts
Normal 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;
|
||||
}
|
||||
447
apps/web/src/lib/features/sidebar/sidebar.server.ts
Normal file
447
apps/web/src/lib/features/sidebar/sidebar.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
41
apps/web/src/lib/features/theme/ThemeHead.svelte
Normal file
41
apps/web/src/lib/features/theme/ThemeHead.svelte
Normal 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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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!);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
28
apps/web/src/lib/features/tournament/tournament-types.ts
Normal file
28
apps/web/src/lib/features/tournament/tournament-types.ts
Normal 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;
|
||||
}
|
||||
42
apps/web/src/lib/features/tournament/tournament-utils.ts
Normal file
42
apps/web/src/lib/features/tournament/tournament-utils.ts
Normal 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];
|
||||
}
|
||||
11
apps/web/src/lib/features/trophies/trophies-utils.ts
Normal file
11
apps/web/src/lib/features/trophies/trophies-utils.ts
Normal 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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
10
apps/web/src/lib/utils/format-distance.ts
Normal file
10
apps/web/src/lib/utils/format-distance.ts
Normal 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 });
|
||||
}
|
||||
8
apps/web/src/lib/utils/git-commit.ts
Normal file
8
apps/web/src/lib/utils/git-commit.ts
Normal 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__;
|
||||
@@ -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`;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
14
apps/web/src/routes/leaderboards/+page.ts
Normal file
14
apps/web/src/routes/leaderboards/+page.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
51
apps/web/static/app.webmanifest
Normal file
51
apps/web/static/app.webmanifest
Normal 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
BIN
apps/web/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
2
apps/web/static/robots.txt
Normal file
2
apps/web/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user