Exclude the Svelte workspaces from Biome; formatting pass

apps/web and packages/components get the Prettier+ESLint stack later per
svelte-big-bang.md; Biome stays scoped to the React app and tooling.
This commit is contained in:
Kalle
2026-08-16 12:11:21 +03:00
parent 8f3caa4683
commit acdfbc5d4d
102 changed files with 2328 additions and 2293 deletions

View File

@@ -3,7 +3,10 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const WEB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const WEB_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const SETTINGS_PATH = path.join(WEB_DIR, "project.inlang/settings.json");
const LOCALES_DIR = path.resolve(WEB_DIR, "../web-react/locales");

View File

@@ -2,7 +2,10 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const WEB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const WEB_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const LOCALES_DIR = path.resolve(WEB_DIR, "../web-react/locales");
const OUTPUT_DIR = path.join(WEB_DIR, "messages");

View File

@@ -11,5 +11,3 @@ declare global {
// interface Platform {}
}
}
export {};

View File

@@ -1,10 +1,10 @@
import type { Handle } from "@sveltejs/kit/hooks";
import { userIsBanned } from "#lib/features/ban/banned.server.ts";
import {
AUTH_COOKIE_NAME,
readSessionCookie,
sessionUserId,
} from "#lib/features/auth/session.server.ts";
import { userIsBanned } from "#lib/features/ban/banned.server.ts";
import { getTheme } from "#lib/features/theme/theme.server.ts";
import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts";
import { paraglideMiddleware } from "#lib/paraglide/server.js";

View File

@@ -1,102 +1,102 @@
<script lang="ts" module>
const dimensions = {
xxxs: 16,
xxxsm: 20,
xxs: 24,
xxsm: 32,
xs: 36,
sm: 44,
xsm: 62,
md: 81,
xmd: 94,
lg: 125,
} as const;
const dimensions = {
xxxs: 16,
xxxsm: 20,
xxs: 24,
xxsm: 32,
xs: 36,
sm: 44,
xsm: 62,
md: 81,
xmd: 94,
lg: 125,
} as const;
const identiconCache = new Map<string, string>();
const IDENTICON_CACHE_MAX = 500;
const identiconCache = new Map<string, string>();
const IDENTICON_CACHE_MAX = 500;
function hashString(str: string) {
let hash = 5381;
function hashString(str: string) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
return hash;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
function generateColors(hash: number) {
const hue = hash % 360;
const saturation = 65 + ((hash >>> 8) % 20);
const lightness = 50 + ((hash >>> 16) % 20);
return hash;
}
return {
background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`,
foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
};
}
function generateColors(hash: number) {
const hue = hash % 360;
const saturation = 65 + ((hash >>> 8) % 20);
const lightness = 50 + ((hash >>> 16) % 20);
export function generateIdenticon(input: string, size = 128, gridSize = 5) {
const cacheKey = `${input}-${size}-${gridSize}`;
const cached = identiconCache.get(cacheKey);
if (cached) return cached;
return {
background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`,
foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
};
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
export function generateIdenticon(input: string, size = 128, gridSize = 5) {
const cacheKey = `${input}-${size}-${gridSize}`;
const cached = identiconCache.get(cacheKey);
if (cached) return cached;
const dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
const insetRatio = 1 / Math.sqrt(2);
const cellSize = Math.floor((size * insetRatio) / gridSize);
const actualSize = cellSize * gridSize;
const offset = Math.floor((size - actualSize) / 2);
const halfGrid = Math.ceil(gridSize / 2);
const dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false;
const patternHash = hashString(input);
const colorHash = hashString(input.split("").reverse().join(""));
const insetRatio = 1 / Math.sqrt(2);
const cellSize = Math.floor((size * insetRatio) / gridSize);
const actualSize = cellSize * gridSize;
const offset = Math.floor((size - actualSize) / 2);
const halfGrid = Math.ceil(gridSize / 2);
const colors = generateColors(colorHash);
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = colors.foreground;
const patternHash = hashString(input);
const colorHash = hashString(input.split("").reverse().join(""));
const path = new Path2D();
const colors = generateColors(colorHash);
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = colors.foreground;
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < halfGrid; col++) {
const bitIndex = row * halfGrid + col;
const shouldFill = (patternHash >>> bitIndex) & 1;
const path = new Path2D();
if (shouldFill) {
const x = offset + col * cellSize;
const y = offset + row * cellSize;
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < halfGrid; col++) {
const bitIndex = row * halfGrid + col;
const shouldFill = (patternHash >>> bitIndex) & 1;
path.rect(x, y, cellSize, cellSize);
if (shouldFill) {
const x = offset + col * cellSize;
const y = offset + row * cellSize;
const mirrorCol = gridSize - 1 - col;
if (col !== mirrorCol) {
const mirrorX = offset + mirrorCol * cellSize;
path.rect(mirrorX, y, cellSize, cellSize);
}
path.rect(x, y, cellSize, cellSize);
const mirrorCol = gridSize - 1 - col;
if (col !== mirrorCol) {
const mirrorX = offset + mirrorCol * cellSize;
path.rect(mirrorX, y, cellSize, cellSize);
}
}
}
ctx.fill(path);
const dataUrl = canvas.toDataURL();
if (identiconCache.size >= IDENTICON_CACHE_MAX) {
identiconCache.clear();
}
identiconCache.set(cacheKey, dataUrl);
return dataUrl;
}
ctx.fill(path);
const dataUrl = canvas.toDataURL();
if (identiconCache.size >= IDENTICON_CACHE_MAX) {
identiconCache.clear();
}
identiconCache.set(cacheKey, dataUrl);
return dataUrl;
}
</script>
<script lang="ts">

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import { getLocale } from "#lib/paraglide/runtime.js";
import { countryCodeToTranslatedName } from "#lib/utils/i18n.ts";
import { getLocale } from "#lib/paraglide/runtime.js";
import { countryCodeToTranslatedName } from "#lib/utils/i18n.ts";
interface Props {
countryCode: string;
tiny?: boolean;
}
interface Props {
countryCode: string;
tiny?: boolean;
}
let { countryCode, tiny = false }: Props = $props();
let { countryCode, tiny = false }: Props = $props();
</script>
<span

View File

@@ -1,30 +1,30 @@
<script lang="ts" module>
let pendingRemoteRequests = $state(0);
let fetchPatched = false;
let pendingRemoteRequests = $state(0);
let fetchPatched = false;
// remote function calls go over plain fetch; counting the in-flight ones is
// what lets e2e (and the differ) wait for "router idle" after interactions
function patchFetchOnce() {
if (fetchPatched || typeof window === "undefined") return;
fetchPatched = true;
// remote function calls go over plain fetch; counting the in-flight ones is
// what lets e2e (and the differ) wait for "router idle" after interactions
function patchFetchOnce() {
if (fetchPatched || typeof window === "undefined") return;
fetchPatched = true;
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const isAppRequest = url.includes("/_app/") || url.startsWith("/");
if (isAppRequest) pendingRemoteRequests++;
try {
return await originalFetch(input, init);
} finally {
if (isAppRequest) pendingRemoteRequests--;
}
};
}
const originalFetch = window.fetch.bind(window);
window.fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const isAppRequest = url.includes("/_app/") || url.startsWith("/");
if (isAppRequest) pendingRemoteRequests++;
try {
return await originalFetch(input, init);
} finally {
if (isAppRequest) pendingRemoteRequests--;
}
};
}
</script>
<script lang="ts">

View File

@@ -1,35 +1,35 @@
<script lang="ts">
interface Props {
path: string;
alt: string;
title?: string;
class?: string;
containerClass?: string;
width?: number;
height?: number;
size?: number;
style?: string;
containerStyle?: string;
testId?: string;
onclick?: () => void;
loading?: "lazy" | "eager";
}
interface Props {
path: string;
alt: string;
title?: string;
class?: string;
containerClass?: string;
width?: number;
height?: number;
size?: number;
style?: string;
containerStyle?: string;
testId?: string;
onclick?: () => void;
loading?: "lazy" | "eager";
}
let {
path,
alt,
title,
class: className,
containerClass,
width,
height,
size,
style,
containerStyle,
testId,
onclick,
loading = "lazy",
}: Props = $props();
let {
path,
alt,
title,
class: className,
containerClass,
width,
height,
size,
style,
containerStyle,
testId,
onclick,
loading = "lazy",
}: Props = $props();
</script>
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import { Popover } from "@sendou/components";
import type { Snippet } from "svelte";
import { Popover } from "@sendou/components";
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
tiny?: boolean;
class?: string;
}
interface Props {
children: Snippet;
tiny?: boolean;
class?: string;
}
let { children, tiny = false, class: className }: Props = $props();
let { children, tiny = false, class: className }: Props = $props();
</script>
<Popover>

View File

@@ -1,25 +1,25 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
class?: string;
halfWidth?: boolean;
bigger?: boolean;
breakoutContainer?: boolean;
style?: string;
testId?: string;
}
interface Props {
children: Snippet;
class?: string;
halfWidth?: boolean;
bigger?: boolean;
breakoutContainer?: boolean;
style?: string;
testId?: string;
}
let {
children,
class: className,
halfWidth,
bigger,
breakoutContainer,
style,
testId,
}: Props = $props();
let {
children,
class: className,
halfWidth,
bigger,
breakoutContainer,
style,
testId,
}: Props = $props();
</script>
<main

View File

@@ -1,28 +1,28 @@
<script lang="ts">
import { page } from "$app/state";
import { Config } from "#lib/config.ts";
import { truncateBySentence } from "#lib/utils/strings.ts";
import { Config } from "#lib/config.ts";
import { truncateBySentence } from "#lib/utils/strings.ts";
import { page } from "$app/state";
interface Props {
title: string;
ogTitle?: string;
description?: string;
url?: string;
image?: { url: string; dimensions?: { width: number; height: number } };
}
interface Props {
title: string;
ogTitle?: string;
description?: string;
url?: string;
image?: { url: string; dimensions?: { width: number; height: number } };
}
let { title, ogTitle, description, url, image }: Props = $props();
let { title, ogTitle, description, url, image }: Props = $props();
const ROOT_URL = "https://sendou.ink";
const COMMON_PREVIEW_IMAGE = `${Config.staticAssetsUrl}/img/layout/common-preview.png`;
const ROOT_URL = "https://sendou.ink";
const COMMON_PREVIEW_IMAGE = `${Config.staticAssetsUrl}/img/layout/common-preview.png`;
const fullTitle = $derived(
title === "sendou.ink" ? title : `${title} | sendou.ink`,
);
const truncatedDescription = $derived(
description ? truncateBySentence(description, 300) : null,
);
const ogUrl = $derived(`${ROOT_URL}${url ?? page.url.pathname}`);
const fullTitle = $derived(
title === "sendou.ink" ? title : `${title} | sendou.ink`,
);
const truncatedDescription = $derived(
description ? truncateBySentence(description, 300) : null,
);
const ogUrl = $derived(`${ROOT_URL}${url ?? page.url.pathname}`);
</script>
<svelte:head>

View File

@@ -1,22 +1,22 @@
<script lang="ts">
import { modeLongName } from "#lib/modules/i18n/messages.ts";
import { modeImageUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
import { modeLongName } from "#lib/modules/i18n/messages.ts";
import { modeImageUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
interface Props {
mode: string;
title?: string;
class?: string;
width?: number;
height?: number;
size?: number;
testId?: string;
loading?: "lazy" | "eager";
}
interface Props {
mode: string;
title?: string;
class?: string;
width?: number;
height?: number;
size?: number;
testId?: string;
loading?: "lazy" | "eager";
}
let { mode, title, ...rest }: Props = $props();
let { mode, title, ...rest }: Props = $props();
const label = $derived(title ?? modeLongName(mode));
const label = $derived(title ?? modeLongName(mode));
</script>
<Image {...rest} alt={label} title={label} path={modeImageUrl(mode)} />

View File

@@ -1,10 +1,10 @@
<script lang="ts">
interface Props {
class?: string;
testId?: string;
}
interface Props {
class?: string;
testId?: string;
}
let { class: className, testId }: Props = $props();
let { class: className, testId }: Props = $props();
</script>
<span class={["dotWrapper", className]} data-testid={testId}>

View File

@@ -1,49 +1,49 @@
<script lang="ts">
import { getLocale } from "#lib/paraglide/runtime.js";
import { ordinalSuffix } from "#lib/utils/i18n.ts";
import {
FIRST_PLACEMENT_ICON_PATH,
SECOND_PLACEMENT_ICON_PATH,
THIRD_PLACEMENT_ICON_PATH,
} from "#lib/utils/urls.ts";
import { getLocale } from "#lib/paraglide/runtime.js";
import { ordinalSuffix } from "#lib/utils/i18n.ts";
import {
FIRST_PLACEMENT_ICON_PATH,
SECOND_PLACEMENT_ICON_PATH,
THIRD_PLACEMENT_ICON_PATH,
} from "#lib/utils/urls.ts";
interface Props {
placement: number;
iconClass?: string;
textClass?: string;
size?: number;
textOnly?: boolean;
showAsSuperscript?: boolean;
interface Props {
placement: number;
iconClass?: string;
textClass?: string;
size?: number;
textOnly?: boolean;
showAsSuperscript?: boolean;
}
let {
placement,
iconClass,
textClass,
size = 20,
textOnly = false,
showAsSuperscript = true,
}: Props = $props();
const suffix = $derived(ordinalSuffix(placement, getLocale()));
const isSuperscript = $derived(showAsSuperscript && suffix.startsWith("^"));
const ordinalSuffixText = $derived(suffix.replace(/^\^/, ""));
const iconPath = $derived.by(() => {
if (textOnly) return null;
switch (placement) {
case 3:
return THIRD_PLACEMENT_ICON_PATH;
case 2:
return SECOND_PLACEMENT_ICON_PATH;
case 1:
return FIRST_PLACEMENT_ICON_PATH;
default:
return null;
}
});
let {
placement,
iconClass,
textClass,
size = 20,
textOnly = false,
showAsSuperscript = true,
}: Props = $props();
const suffix = $derived(ordinalSuffix(placement, getLocale()));
const isSuperscript = $derived(showAsSuperscript && suffix.startsWith("^"));
const ordinalSuffixText = $derived(suffix.replace(/^\^/, ""));
const iconPath = $derived.by(() => {
if (textOnly) return null;
switch (placement) {
case 3:
return THIRD_PLACEMENT_ICON_PATH;
case 2:
return SECOND_PLACEMENT_ICON_PATH;
case 1:
return FIRST_PLACEMENT_ICON_PATH;
default:
return null;
}
});
const placementString = $derived(`${placement}${ordinalSuffixText}`);
const placementString = $derived(`${placement}${ordinalSuffixText}`);
</script>
{#if !iconPath}

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { Config } from "#lib/config.ts";
import { PWA_SPLASH_SCREENS } from "./pwa-splash-screens.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}`;
const APP_ICON_URL = `${Config.staticAssetsUrl}/img/app-icon.png`;
const splashScreenUrl = (fileName: string) =>
`${Config.staticAssetsUrl}/img/splash-screens/${fileName}`;
</script>
<svelte:head>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import type { TierName } from "#lib/features/mmr/mmr-constants.ts";
import { TIER_PLUS_URL, tierImageUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
import type { TierName } from "#lib/features/mmr/mmr-constants.ts";
import { TIER_PLUS_URL, tierImageUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
interface Props {
tier: { name: TierName; isPlus: boolean };
class?: string;
width?: number;
}
interface Props {
tier: { name: TierName; isPlus: boolean };
class?: string;
width?: number;
}
let { tier, class: className, width = 200 }: Props = $props();
let { tier, class: className, width = 200 }: Props = $props();
const title = $derived(`${tier.name}${tier.isPlus ? "+" : ""}`);
const height = $derived(width * 0.8675);
const title = $derived(`${tier.name}${tier.isPlus ? "+" : ""}`);
const height = $derived(width * 0.8675);
</script>
<div class={["tierContainer", className]} style="width: {width}px">

View File

@@ -1,29 +1,29 @@
<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 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 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];
const POLISHED_TIERS = [1, 2, 3];
</script>
<script lang="ts">

View File

@@ -1,61 +1,61 @@
<script lang="ts">
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { mainWeaponName } from "#lib/modules/i18n/messages.ts";
import {
mainWeaponImageUrl,
outlinedFiveStarMainWeaponImageUrl,
outlinedMainWeaponImageUrl,
outlinedTenStarMainWeaponImageUrl,
} from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { mainWeaponName } from "#lib/modules/i18n/messages.ts";
import {
mainWeaponImageUrl,
outlinedFiveStarMainWeaponImageUrl,
outlinedMainWeaponImageUrl,
outlinedTenStarMainWeaponImageUrl,
} from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
type WeaponWithStars = {
weaponSplId: MainWeaponId;
isFavorite?: boolean | number;
isTenStar?: boolean | number;
};
type WeaponWithStars = {
weaponSplId: MainWeaponId;
isFavorite?: boolean | number;
isTenStar?: boolean | number;
};
interface Props {
weaponSplId?: MainWeaponId;
weapon?: WeaponWithStars;
variant?: "badge" | "badge-5-star" | "badge-10-star" | "build";
title?: string;
class?: string;
width?: number;
height?: number;
size?: number;
testId?: string;
loading?: "lazy" | "eager";
}
interface Props {
weaponSplId?: MainWeaponId;
weapon?: WeaponWithStars;
variant?: "badge" | "badge-5-star" | "badge-10-star" | "build";
title?: string;
class?: string;
width?: number;
height?: number;
size?: number;
testId?: string;
loading?: "lazy" | "eager";
}
let {
weaponSplId: weaponSplIdProp,
weapon,
variant: variantProp,
title,
...rest
}: Props = $props();
let {
weaponSplId: weaponSplIdProp,
weapon,
variant: variantProp,
title,
...rest
}: Props = $props();
const weaponSplId = $derived(weapon?.weaponSplId ?? weaponSplIdProp!);
const variant = $derived(
variantProp ?? (weapon ? resolveWeaponBadgeVariant(weapon) : "badge"),
);
const label = $derived(title ?? mainWeaponName(weaponSplId));
const path = $derived(
variant === "badge"
? outlinedMainWeaponImageUrl(weaponSplId)
: variant === "badge-5-star"
? outlinedFiveStarMainWeaponImageUrl(weaponSplId)
: variant === "badge-10-star"
? outlinedTenStarMainWeaponImageUrl(weaponSplId)
: mainWeaponImageUrl(weaponSplId),
);
const weaponSplId = $derived(weapon?.weaponSplId ?? weaponSplIdProp!);
const variant = $derived(
variantProp ?? (weapon ? resolveWeaponBadgeVariant(weapon) : "badge"),
);
const label = $derived(title ?? mainWeaponName(weaponSplId));
const path = $derived(
variant === "badge"
? outlinedMainWeaponImageUrl(weaponSplId)
: variant === "badge-5-star"
? outlinedFiveStarMainWeaponImageUrl(weaponSplId)
: variant === "badge-10-star"
? outlinedTenStarMainWeaponImageUrl(weaponSplId)
: mainWeaponImageUrl(weaponSplId),
);
function resolveWeaponBadgeVariant(weapon: WeaponWithStars) {
if (weapon.isFavorite && weapon.isTenStar) return "badge-10-star" as const;
if (weapon.isFavorite) return "badge-5-star" as const;
return "badge" as const;
}
function resolveWeaponBadgeVariant(weapon: WeaponWithStars) {
if (weapon.isFavorite && weapon.isTenStar) return "badge-10-star" as const;
if (weapon.isFavorite) return "badge-5-star" as const;
return "badge" as const;
}
</script>
<Image {...rest} alt={label} title={label} {path} />

View File

@@ -1,106 +1,106 @@
<script lang="ts">
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { filterWeapon } from "@sendou/in-game-lists/utils";
import { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import { Select, SelectItem, SelectItemSection } from "@sendou/components";
import { mainWeaponName } from "#lib/modules/i18n/messages.ts";
import { m } from "#lib/paraglide/messages.js";
import { weaponCategoryUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
import WeaponImage from "./WeaponImage.svelte";
import { Select, SelectItem, SelectItemSection } from "@sendou/components";
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { filterWeapon } from "@sendou/in-game-lists/utils";
import { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import { mainWeaponName } from "#lib/modules/i18n/messages.ts";
import { m } from "#lib/paraglide/messages.js";
import { weaponCategoryUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
import WeaponImage from "./WeaponImage.svelte";
interface Props {
label?: string;
value?: MainWeaponId | null;
initialValue?: MainWeaponId;
onChange?: (weaponId: MainWeaponId | null) => void;
clearable?: boolean;
disabledWeaponIds?: Array<MainWeaponId>;
testId?: string;
isRequired?: boolean;
/** If set, selection of weapons that user sees when search input is empty allowing for quick select for e.g. previous selections */
quickSelectWeaponsIds?: Array<MainWeaponId>;
isDisabled?: boolean;
placeholder?: string;
}
interface Props {
label?: string;
value?: MainWeaponId | null;
initialValue?: MainWeaponId;
onChange?: (weaponId: MainWeaponId | null) => void;
clearable?: boolean;
disabledWeaponIds?: Array<MainWeaponId>;
testId?: string;
isRequired?: boolean;
/** If set, selection of weapons that user sees when search input is empty allowing for quick select for e.g. previous selections */
quickSelectWeaponsIds?: Array<MainWeaponId>;
isDisabled?: boolean;
placeholder?: string;
}
let {
label,
value,
initialValue,
onChange,
clearable,
disabledWeaponIds,
testId = "weapon-select",
isRequired,
quickSelectWeaponsIds,
isDisabled,
placeholder,
}: Props = $props();
let {
label,
value,
initialValue,
onChange,
clearable,
disabledWeaponIds,
testId = "weapon-select",
isRequired,
quickSelectWeaponsIds,
isDisabled,
placeholder,
}: Props = $props();
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = value !== undefined;
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = value !== undefined;
let searchValue = $state("");
let searchValue = $state("");
const allCategories = $derived(
weaponCategories.map((category) => ({
name: category.name,
items: category.weaponIds.map((id) => ({
id: id as MainWeaponId,
name: mainWeaponName(id),
})),
const allCategories = $derived(
weaponCategories.map((category) => ({
name: category.name,
items: category.weaponIds.map((id) => ({
id: id as MainWeaponId,
name: mainWeaponName(id),
})),
);
})),
);
const visibleCategories = $derived.by(() => {
if (searchValue === "" && quickSelectWeaponsIds?.length) {
const weaponIdsToInclude = new Set(quickSelectWeaponsIds);
if (typeof value === "number") {
weaponIdsToInclude.add(value);
}
return [
{
name: m.common_forms_weaponSearch_quickSelect(),
isQuickSelect: true,
items: allCategories
.flatMap((category) => category.items)
.filter((item) => weaponIdsToInclude.has(item.id))
.sort(
(a, b) =>
quickSelectWeaponsIds.indexOf(a.id) -
quickSelectWeaponsIds.indexOf(b.id),
),
},
];
const visibleCategories = $derived.by(() => {
if (searchValue === "" && quickSelectWeaponsIds?.length) {
const weaponIdsToInclude = new Set(quickSelectWeaponsIds);
if (typeof value === "number") {
weaponIdsToInclude.add(value);
}
if (searchValue === "") {
return allCategories.map((category) => ({
...category,
isQuickSelect: false,
}));
}
return allCategories
.map((category) => ({
...category,
isQuickSelect: false,
items: category.items.filter((item) =>
filterWeapon({
weapon: { id: item.id, type: "MAIN" },
weaponName: item.name,
searchTerm: searchValue,
}),
),
}))
.filter((category) => category.items.length > 0);
});
function handleSelectionChange(key: string | number | null) {
onChange?.(key === null ? null : (Number(key) as MainWeaponId));
return [
{
name: m.common_forms_weaponSearch_quickSelect(),
isQuickSelect: true,
items: allCategories
.flatMap((category) => category.items)
.filter((item) => weaponIdsToInclude.has(item.id))
.sort(
(a, b) =>
quickSelectWeaponsIds.indexOf(a.id) -
quickSelectWeaponsIds.indexOf(b.id),
),
},
];
}
if (searchValue === "") {
return allCategories.map((category) => ({
...category,
isQuickSelect: false,
}));
}
return allCategories
.map((category) => ({
...category,
isQuickSelect: false,
items: category.items.filter((item) =>
filterWeapon({
weapon: { id: item.id, type: "MAIN" },
weaponName: item.name,
searchTerm: searchValue,
}),
),
}))
.filter((category) => category.items.length > 0);
});
function handleSelectionChange(key: string | number | null) {
onChange?.(key === null ? null : (Number(key) as MainWeaponId));
}
</script>
<Select

View File

@@ -1,5 +1,5 @@
<script lang="ts">
let { class: className }: { class?: string } = $props();
let { class: className }: { class?: string } = $props();
</script>
<svg

View File

@@ -1,5 +1,5 @@
<script lang="ts">
let { class: className }: { class?: string } = $props();
let { class: className }: { class?: string } = $props();
</script>
<svg

View File

@@ -1,5 +1,5 @@
<script lang="ts">
let { class: className }: { class?: string } = $props();
let { class: className }: { class?: string } = $props();
</script>
<svg

View File

@@ -1,5 +1,5 @@
<script lang="ts">
let { class: className }: { class?: string } = $props();
let { class: className }: { class?: string } = $props();
</script>
<svg

View File

@@ -1,43 +1,43 @@
<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";
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;
}
interface Props {
compact?: boolean;
}
let { compact }: Props = $props();
let { compact }: Props = $props();
const user = $derived(loggedInUser());
const user = $derived(loggedInUser());
const items = $derived.by(() => {
if (!user) return [];
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,
];
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);
});
return all.filter((item) => item !== null);
});
</script>
{#if user}

View File

@@ -1,65 +1,65 @@
<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";
import { isToday, isTomorrow } from "date-fns";
import { m } from "#lib/paraglide/messages.js";
import { getLocale } from "#lib/paraglide/runtime.js";
import ListLink from "./ListLink.svelte";
import type { SidebarEvent } from "./layout-types.ts";
interface Props {
events: SidebarEvent[];
onclick?: () => void;
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);
}
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 });
}
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;
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()];
});
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}

View File

@@ -1,37 +1,37 @@
<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";
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 Patron {
id: number;
username: string;
discordId: string;
customUrl?: string | null;
}
interface Props {
patrons?: Patron[];
gitCommit?: string | null;
}
interface Props {
patrons?: Patron[];
gitCommit?: string | null;
}
let { patrons, gitCommit }: Props = $props();
let { patrons, gitCommit }: Props = $props();
const currentYear = new Date().getFullYear();
const currentYear = new Date().getFullYear();
</script>
<footer class="footer">

View File

@@ -1,55 +1,54 @@
<script lang="ts" module>
type FriendActivityBadge = "MATCH" | "NEXT";
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,
};
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;
}
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">

View File

@@ -1,10 +1,10 @@
<script lang="ts">
interface Props {
tabCount: number;
onTabPress: (index: number) => void;
}
interface Props {
tabCount: number;
onTabPress: (index: number) => void;
}
let { tabCount, onTabPress }: Props = $props();
let { tabCount, onTabPress }: Props = $props();
</script>
<div class="ghostTabBar" aria-hidden="true">

View File

@@ -1,221 +1,216 @@
<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";
import { Calendar, ChevronRight, PanelLeft, Tv, Users } from "@lucide/svelte";
import type { Snippet } from "svelte";
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 { page } from "$app/state";
import Footer from "./Footer.svelte";
import FriendMenu from "./FriendMenu.svelte";
import ListLink from "./ListLink.svelte";
import type { NotificationRow, SidebarData } from "./layout-types.ts";
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;
const MAX_DESKTOP_FRIENDS = 4;
interface Breadcrumb {
type: "IMAGE" | "TEXT";
imgPath?: string;
href: string;
text?: string;
identiconInput?: string;
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;
}
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;
void getNotifications().then((result) => {
notifications = result.notifications
? toNotificationRows(result.notifications)
: null;
});
});
// the bell data loads lazily like the React app's NotificationsProvider:
// after hydration, never blocking SSR
$effect(() => {
if (!user) {
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;
}
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 navHeight = headerElement?.offsetHeight ?? NAV_HEIGHT_FALLBACK;
const currentScrollY = window.scrollY;
const scrollDelta = currentScrollY - lastScrollY;
const patrons = $derived(await getPatrons());
const directionChanged =
(scrollDelta > 0 && scrollAccumulator < 0) ||
(scrollDelta < 0 && scrollAccumulator > 0);
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)}`;
if (directionChanged) {
scrollAccumulator = 0;
}
return dateTimeFormatter.format(date);
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()}

View File

@@ -1,32 +1,32 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Avatar from "#lib/components/Avatar.svelte";
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";
}
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();
let {
children,
user,
subtitle,
badge,
badgeVariant,
onclick,
"aria-expanded": ariaExpanded,
"aria-haspopup": ariaHaspopup,
}: Props = $props();
</script>
<button

View File

@@ -1,36 +1,36 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Avatar from "#lib/components/Avatar.svelte";
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";
}
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();
let {
children,
to,
onclick,
isActive,
imageUrl,
overlayIconUrl,
user,
subtitle,
badge,
badgeVariant,
}: Props = $props();
</script>
<a

View File

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

View File

@@ -1,93 +1,92 @@
<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";
import {
Calendar,
ChevronRight,
LogIn,
Menu,
MessageSquare,
Settings,
User,
Users,
} from "@lucide/svelte";
import Avatar from "#lib/components/Avatar.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 {
EVENTS_PAGE,
FRIENDS_PAGE,
SETTINGS_PAGE,
userPage,
} from "#lib/utils/urls.ts";
import EventsList from "./EventsList.svelte";
import FriendMenu from "./FriendMenu.svelte";
import LogInButtonContainer from "./LogInButtonContainer.svelte";
import type { NotificationRow, SidebarData } from "./layout-types.ts";
import MobileNavMenuOverlay from "./MobileNavMenuOverlay.svelte";
import MobilePanel from "./MobilePanel.svelte";
import MobileTab from "./MobileTab.svelte";
type PanelType = "closed" | "menu" | "friends" | "tourneys" | "chat" | "you";
type PanelType = "closed" | "menu" | "friends" | "tourneys" | "chat" | "you";
const SENDOUQ_ACTIVITY_LABEL = "SendouQ";
const SENDOUQ_ACTIVITY_LABEL = "SendouQ";
interface Props {
sidebarData: SidebarData | undefined;
notifications?: NotificationRow[];
unseenNotificationIds?: number[];
showUnseenDot?: boolean;
}
interface Props {
sidebarData: SidebarData | undefined;
notifications?: NotificationRow[];
unseenNotificationIds?: number[];
showUnseenDot?: boolean;
}
let {
sidebarData,
notifications,
unseenNotificationIds,
showUnseenDot = false,
}: Props = $props();
let {
sidebarData,
notifications,
unseenNotificationIds,
showUnseenDot = false,
}: Props = $props();
let activePanel = $state<PanelType>("closed");
let previousPanel: PanelType = "closed";
let activePanel = $state<PanelType>("closed");
let previousPanel: PanelType = "closed";
const user = $derived(loggedInUser());
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 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 skipAnimation = $derived(previousPanel !== "closed");
const loggedInTabs: PanelType[] = [
"menu",
"friends",
"tourneys",
"chat",
"you",
];
const loggedOutTabs: PanelType[] = ["menu"];
const ghostTabs = $derived(user ? loggedInTabs : loggedOutTabs);
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 closePanel() {
previousPanel = activePanel;
activePanel = "closed";
}
function handleTabPress(panel: PanelType) {
// TODO(chat rebuild): the chat panel arrives with the scrims slice
if (panel === "chat") return;
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;
}
previousPanel = activePanel;
activePanel = activePanel === panel ? "closed" : panel;
}
function handleGhostTabPress(index: number) {
const panel = ghostTabs[index];
if (panel) handleTabPress(panel);
}
function handleGhostTabPress(index: number) {
const panel = ghostTabs[index];
if (panel) handleTabPress(panel);
}
</script>
<div class="mobileNav">

View File

@@ -1,59 +1,59 @@
<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";
import { Heart, Menu, Share2, Tv, X } from "@lucide/svelte";
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 { browser } from "$app/env";
import { page } from "$app/state";
import GhostTabBar from "./GhostTabBar.svelte";
import type { SidebarStream } from "./layout-types.ts";
import { navItems } from "./nav-items.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}`,
});
}
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 }]}>

View File

@@ -1,27 +1,27 @@
<script lang="ts">
import { X } from "@lucide/svelte";
import type { Snippet } from "svelte";
import GhostTabBar from "./GhostTabBar.svelte";
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;
}
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();
let {
title,
icon,
onClose,
children,
ghostTabCount,
onGhostTabPress,
skipAnimation,
}: Props = $props();
</script>
<div class={["panelOverlay", { noAnimation: skipAnimation }]}>

View File

@@ -1,30 +1,30 @@
<script lang="ts">
import type { Snippet } from "svelte";
import NotificationDot from "#lib/components/NotificationDot.svelte";
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;
}
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();
let {
icon,
label,
isActive,
onPress,
showNotificationDot,
unreadCount,
badgeCount,
badgeLeft,
}: Props = $props();
const count = $derived(unreadCount ?? badgeCount);
const count = $derived(unreadCount ?? badgeCount);
</script>
<button type="button" class="tab" data-active={isActive} onclick={onPress}>

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
class?: string;
footer?: Snippet;
top?: Snippet;
topCentered?: boolean;
collapsed?: boolean;
}
interface Props {
children: Snippet;
class?: string;
footer?: Snippet;
top?: Snippet;
topCentered?: boolean;
collapsed?: boolean;
}
let {
children,
class: className,
footer,
top,
topCentered,
collapsed,
}: Props = $props();
let {
children,
class: className,
footer,
top,
topCentered,
collapsed,
}: Props = $props();
</script>
<nav

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
}
interface Props {
children: Snippet;
}
let { children }: Props = $props();
let { children }: Props = $props();
</script>
<div class="sideNavFooter">

View File

@@ -1,13 +1,13 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
icon?: Snippet;
action?: Snippet;
}
interface Props {
children: Snippet;
icon?: Snippet;
action?: Snippet;
}
let { children, icon, action }: Props = $props();
let { children, icon, action }: Props = $props();
</script>
<header data-testid="side-nav-header" class="sideNavHeader">

View File

@@ -1,24 +1,24 @@
<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";
import { Bell, LogIn, Settings } from "@lucide/svelte";
import { Popover } from "@sendou/components";
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 { page } from "$app/state";
import type { NotificationRow } from "./layout-types.ts";
interface Props {
notifications: NotificationRow[] | null;
unseenIds: number[];
showUnseenDot: boolean;
}
interface Props {
notifications: NotificationRow[] | null;
unseenIds: number[];
showUnseenDot: boolean;
}
let { notifications, unseenIds, showUnseenDot }: Props = $props();
let { notifications, unseenIds, showUnseenDot }: Props = $props();
const user = $derived(loggedInUser());
const user = $derived(loggedInUser());
</script>
{#if user}

View File

@@ -1,64 +1,64 @@
<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";
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 { m } from "#lib/paraglide/messages.js";
import { getLocale } from "#lib/paraglide/runtime.js";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import { formatDistanceToNowLocalized } from "#lib/utils/format-distance.ts";
import { navIconUrl } from "#lib/utils/urls.ts";
import ListLink from "./ListLink.svelte";
import type { SidebarStream } from "./layout-types.ts";
interface Props {
streams: SidebarStream[];
onclick?: () => void;
isLoggedIn?: boolean;
savedTournamentIds?: number[];
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}`;
}
let { streams, onclick, isLoggedIn, savedTournamentIds }: Props = $props();
return dateTimeFormatter.format(date);
}
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 isUpcoming(stream: SidebarStream) {
return databaseTimestampToDate(stream.startsAt).getTime() > Date.now();
}
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;
}
function tournamentIdOf(stream: SidebarStream) {
return stream.id.startsWith("upcoming-")
? Number(stream.id.replace("upcoming-", ""))
: null;
}
</script>
{#each streams as stream, i (stream.id)}

View File

@@ -1,51 +1,51 @@
<script lang="ts" module>
import { Config } from "#lib/config.ts";
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;
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;
}
interface NavCategoryItem {
name: string;
url: string;
icon?: string;
staffOnly?: boolean;
}
</script>
<script lang="ts">

View File

@@ -1,33 +1,33 @@
<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";
import { Heart, LogIn, MessageSquare, Search } from "@lucide/svelte";
import { Button } from "@sendou/components";
import { m } from "#lib/paraglide/messages.js";
import { SUPPORT_PAGE } from "#lib/utils/urls.ts";
import { browser } from "$app/env";
import AnythingAdder from "./AnythingAdder.svelte";
import LogInButtonContainer from "./LogInButtonContainer.svelte";
interface Props {
showSupport: boolean;
showSearch: boolean;
isLoggedIn: boolean;
onChatToggle?: () => void;
onChatModalToggle?: () => void;
chatUnreadCount?: number;
}
interface Props {
showSupport: boolean;
showSearch: boolean;
isLoggedIn: boolean;
onChatToggle?: () => void;
onChatModalToggle?: () => void;
chatUnreadCount?: number;
}
let {
showSupport,
showSearch,
isLoggedIn,
onChatToggle,
onChatModalToggle,
chatUnreadCount,
}: Props = $props();
let {
showSupport,
showSearch,
isLoggedIn,
onChatToggle,
onChatModalToggle,
chatUnreadCount,
}: Props = $props();
const isMac = $derived(
browser && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent),
);
const isMac = $derived(
browser && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent),
);
</script>
<div class="container">

View File

@@ -4,139 +4,185 @@
*/
export const PWA_SPLASH_SCREENS = [
{
media: "screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)",
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)",
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: 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: 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: 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)",
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)",
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: 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: 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)",
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)",
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)",
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)",
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)",
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)",
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: 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)",
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)",
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)",
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: 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: 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: 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)",
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)",
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: 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: 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)",
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)",
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)",
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)",
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)",
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)",
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: 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)",
media:
"screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)",
fileName: "8.3__iPad_Mini_portrait.png",
},
] as const;

View File

@@ -123,7 +123,7 @@ function myUnescape(value: string): string {
let result = "";
let index = 0;
while (index < value.length) {
let chr = value.charAt(index++);
const chr = value.charAt(index++);
if (chr === "%") {
if (value.charAt(index) === "u") {
const part = value.slice(index + 1, index + 5);

View File

@@ -1,5 +1,5 @@
import { page } from "$app/state";
import type { Role } from "#lib/modules/permissions/types.ts";
import { page } from "$app/state";
import type { AuthenticatedUser } from "./user-types.ts";
/** The logged in user as serialized to the client by the root layout. */

View File

@@ -1,5 +1,5 @@
import { cache } from "#lib/server/cache.ts";
import type { TournamentTierNumber } from "#lib/features/tournament/tournament-types.ts";
import { cache } from "#lib/server/cache.ts";
export const COMBINED_STREAMS_KEY = "combined-streams";

View File

@@ -1,6 +1,6 @@
import { type SelectQueryBuilder, sql } from "kysely";
import type { DB } from "#lib/server/db/tables.ts";
import { db } from "#lib/server/db/sql.ts";
import type { DB } from "#lib/server/db/tables.ts";
import { commonUserSelect } from "#lib/server/kysely.ts";
import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts";

View File

@@ -1,6 +1,6 @@
import {
findOwnGroupLite,
FULL_GROUP_SIZE,
findOwnGroupLite,
groupExpiryStatus,
type LiteGroup,
} from "#lib/features/sendouq/q-groups.server.ts";

View File

@@ -270,7 +270,9 @@ function resolveFirstPlacers(
}
if (tournament.firstPlacers.every((p) => p.div === null)) {
return [buildFirstPlacerEntry(tournament.firstPlacers, { withMembers: true })];
return [
buildFirstPlacerEntry(tournament.firstPlacers, { withMembers: true }),
];
}
const byDiv = R.groupBy(tournament.firstPlacers, (p) => p.div ?? "");

View File

@@ -1,5 +1,5 @@
import { query } from "$app/server";
import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts";
import { query } from "$app/server";
export const getPatrons = query(async () => {
return UserRepository.findAllPatrons();

View File

@@ -16,8 +16,8 @@ import {
skillCountsAsSeasonSet,
} from "#lib/server/kysely.ts";
import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts";
import * as Seasons from "../mmr/Seasons.ts";
import { ordinalToSp, type SkillTeamIdentifier } from "../mmr/mmr-utils.ts";
import * as Seasons from "../mmr/Seasons.ts";
import {
DEFAULT_LEADERBOARD_MAX_SIZE,
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,

View File

@@ -1,45 +1,40 @@
<script lang="ts">
import invariant from "@sendou/utils/invariant";
import Flag from "#lib/components/Flag.svelte";
import Image from "#lib/components/Image.svelte";
import Placement from "#lib/components/Placement.svelte";
import { winnersImageUrl } from "#lib/utils/urls.ts";
import { topTenPlayerData } from "./leaderboards-utils.ts";
import invariant from "@sendou/utils/invariant";
import Flag from "#lib/components/Flag.svelte";
import Image from "#lib/components/Image.svelte";
import Placement from "#lib/components/Placement.svelte";
import { winnersImageUrl } from "#lib/utils/urls.ts";
import { topTenPlayerData } from "./leaderboards-utils.ts";
interface Props {
power?: number;
placement: number;
season: number;
small?: boolean;
interface Props {
power?: number;
placement: number;
season: number;
small?: boolean;
}
let { power, placement, season, small = false }: Props = $props();
const data = $derived.by(() => {
const player = topTenPlayerData({ season, placement });
invariant(player, `No data for season ${season} and placement ${placement}`);
return player;
});
const transformMultiplier = $derived(small ? 1 / 3 : 1);
const containerStyle = $derived.by(() => {
const styles: string[] = [];
if (data.transforms?.top) {
styles.push(`--winner-top: ${data.transforms.top * transformMultiplier}px`);
}
let { power, placement, season, small = false }: Props = $props();
const data = $derived.by(() => {
const player = topTenPlayerData({ season, placement });
invariant(
player,
`No data for season ${season} and placement ${placement}`,
if (data.transforms?.left) {
styles.push(
`--winner-left: ${data.transforms.left * transformMultiplier}px`,
);
return player;
});
const transformMultiplier = $derived(small ? 1 / 3 : 1);
const containerStyle = $derived.by(() => {
const styles: string[] = [];
if (data.transforms?.top) {
styles.push(
`--winner-top: ${data.transforms.top * transformMultiplier}px`,
);
}
if (data.transforms?.left) {
styles.push(
`--winner-left: ${data.transforms.left * transformMultiplier}px`,
);
}
return styles.join("; ") || undefined;
});
}
return styles.join("; ") || undefined;
});
</script>
<div

View File

@@ -1,76 +1,80 @@
<script lang="ts">
import { rankedModesShort } from "@sendou/in-game-lists/modes";
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import {
ChipRadio,
ChipRadioGroup,
Tab,
TabList,
TabPanel,
Tabs,
} from "@sendou/components";
import Image from "#lib/components/Image.svelte";
import Main from "#lib/components/Main.svelte";
import ModeImage from "#lib/components/ModeImage.svelte";
import WeaponSelect from "#lib/components/WeaponSelect.svelte";
import SeasonSelect from "#lib/features/mmr/SeasonSelect.svelte";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
import { searchParamsState } from "#lib/modules/search-params/search-params-state.svelte.ts";
import { m } from "#lib/paraglide/messages.js";
import {
modeLongName,
weaponCategoryName,
} from "#lib/modules/i18n/messages.ts";
import { navIconUrl, userSeasonsPage, weaponCategoryUrl } from "#lib/utils/urls.ts";
import { leaderboardsSearchParams } from "../leaderboards-search-params.ts";
import { seasonHasTopTen } from "../leaderboards-utils.ts";
import { getLeaderboards } from "../leaderboards.remote.ts";
import OwnEntryPeek from "./OwnEntryPeek.svelte";
import PlayersTable from "./PlayersTable.svelte";
import TeamTable from "./TeamTable.svelte";
import TopTenPlayer from "../TopTenPlayer.svelte";
import XPTable from "./XPTable.svelte";
import {
ChipRadio,
ChipRadioGroup,
Tab,
TabList,
TabPanel,
Tabs,
} from "@sendou/components";
import { rankedModesShort } from "@sendou/in-game-lists/modes";
import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import Image from "#lib/components/Image.svelte";
import Main from "#lib/components/Main.svelte";
import ModeImage from "#lib/components/ModeImage.svelte";
import WeaponSelect from "#lib/components/WeaponSelect.svelte";
import SeasonSelect from "#lib/features/mmr/SeasonSelect.svelte";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
import {
modeLongName,
weaponCategoryName,
} from "#lib/modules/i18n/messages.ts";
import { searchParamsState } from "#lib/modules/search-params/search-params-state.svelte.ts";
import { m } from "#lib/paraglide/messages.js";
import {
navIconUrl,
userSeasonsPage,
weaponCategoryUrl,
} from "#lib/utils/urls.ts";
import { getLeaderboards } from "../leaderboards.remote.ts";
import { leaderboardsSearchParams } from "../leaderboards-search-params.ts";
import { seasonHasTopTen } from "../leaderboards-utils.ts";
import TopTenPlayer from "../TopTenPlayer.svelte";
import OwnEntryPeek from "./OwnEntryPeek.svelte";
import PlayersTable from "./PlayersTable.svelte";
import TeamTable from "./TeamTable.svelte";
import XPTable from "./XPTable.svelte";
const params = searchParamsState(leaderboardsSearchParams);
const params = searchParamsState(leaderboardsSearchParams);
const data = $derived(await getLeaderboards(params.current));
const data = $derived(await getLeaderboards(params.current));
const isAllUserLeaderboard = $derived(params.current.type === "USER");
const isAllUserLeaderboard = $derived(params.current.type === "USER");
const selectedTab = $derived(
params.current.type.startsWith("XP")
? "XP"
: params.current.type.startsWith("TEAM")
? "TEAMS"
: "PLAYERS",
);
const selectedTab = $derived(
params.current.type.startsWith("XP")
? "XP"
: params.current.type.startsWith("TEAM")
? "TEAMS"
: "PLAYERS",
);
const showTopTen = $derived(
Boolean(
seasonHasTopTen(data.season) &&
isAllUserLeaderboard &&
data.userLeaderboard,
),
);
const showTopTen = $derived(
Boolean(
seasonHasTopTen(data.season) &&
isAllUserLeaderboard &&
data.userLeaderboard,
),
);
const renderNoEntries = $derived(
(data.userLeaderboard && data.userLeaderboard.length === 0) ||
(data.teamLeaderboard && data.teamLeaderboard.length === 0),
);
const renderNoEntries = $derived(
(data.userLeaderboard && data.userLeaderboard.length === 0) ||
(data.teamLeaderboard && data.teamLeaderboard.length === 0),
);
const selectedWeaponId = $derived(
params.current.type.startsWith("XP-WEAPON")
? (Number(params.current.type.split("-")[2]) as MainWeaponId)
: null,
);
const selectedWeaponId = $derived(
params.current.type.startsWith("XP-WEAPON")
? (Number(params.current.type.split("-")[2]) as MainWeaponId)
: null,
);
function onTabChange(key: string) {
if (key === selectedTab) return;
if (key === "PLAYERS") return params.set({ type: "USER" });
if (key === "TEAMS") return params.set({ type: "TEAM" });
params.set({ type: "XP-ALL", season: null });
}
function onTabChange(key: string) {
if (key === selectedTab) return;
if (key === "PLAYERS") return params.set({ type: "USER" });
if (key === "TEAMS") return params.set({ type: "TEAM" });
params.set({ type: "XP-ALL", season: null });
}
</script>
<Main halfWidth>

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import TierImage from "#lib/components/TierImage.svelte";
import WeaponImage from "#lib/components/WeaponImage.svelte";
import { ordinalToSp } from "#lib/features/mmr/mmr-utils.ts";
import { userSeasonsPage } from "#lib/utils/urls.ts";
import type { OwnEntryPeekData } from "../leaderboards-types.ts";
import Avatar from "#lib/components/Avatar.svelte";
import TierImage from "#lib/components/TierImage.svelte";
import WeaponImage from "#lib/components/WeaponImage.svelte";
import { ordinalToSp } from "#lib/features/mmr/mmr-utils.ts";
import { userSeasonsPage } from "#lib/utils/urls.ts";
import type { OwnEntryPeekData } from "../leaderboards-types.ts";
interface Props {
entry: OwnEntryPeekData["entry"];
nextTier: OwnEntryPeekData["nextTier"];
season: number;
}
interface Props {
entry: OwnEntryPeekData["entry"];
nextTier: OwnEntryPeekData["nextTier"];
season: number;
}
let { entry, nextTier, season }: Props = $props();
let { entry, nextTier, season }: Props = $props();
</script>
<div>

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import TierImage from "#lib/components/TierImage.svelte";
import WeaponImage from "#lib/components/WeaponImage.svelte";
import { userSeasonsPage } from "#lib/utils/urls.ts";
import type { UserLeaderboardEntry } from "../leaderboards-types.ts";
import Avatar from "#lib/components/Avatar.svelte";
import TierImage from "#lib/components/TierImage.svelte";
import WeaponImage from "#lib/components/WeaponImage.svelte";
import { userSeasonsPage } from "#lib/utils/urls.ts";
import type { UserLeaderboardEntry } from "../leaderboards-types.ts";
interface Props {
entries: UserLeaderboardEntry[];
season: number;
showTiers?: boolean;
showingTopTen?: boolean;
}
interface Props {
entries: UserLeaderboardEntry[];
season: number;
showTiers?: boolean;
showingTopTen?: boolean;
}
let { entries, season, showTiers, showingTopTen }: Props = $props();
let { entries, season, showTiers, showingTopTen }: Props = $props();
const shownEntries = $derived(
// hide normal rows that are showed in "fancy" top 10 format
entries.filter((_, i) => !showingTopTen || i > 9),
);
const shownEntries = $derived(
// hide normal rows that are showed in "fancy" top 10 format
entries.filter((_, i) => !showingTopTen || i > 9),
);
</script>
<div class="table">

View File

@@ -1,20 +1,24 @@
<script lang="ts">
import { Button, Menu, MenuItem } from "@sendou/components";
import { Ban, MoreHorizontal, RotateCcw } from "@lucide/svelte";
import { getLeaderboards, skipTeam, unskipTeam } from "../leaderboards.remote.ts";
import type { LeaderboardsQueryArgs } from "../leaderboards-schemas.ts";
import type { TeamLeaderboardEntry } from "../leaderboards-types.ts";
import { Ban, MoreHorizontal, RotateCcw } from "@lucide/svelte";
import { Button, Menu, MenuItem } from "@sendou/components";
import {
getLeaderboards,
skipTeam,
unskipTeam,
} from "../leaderboards.remote.ts";
import type { LeaderboardsQueryArgs } from "../leaderboards-schemas.ts";
import type { TeamLeaderboardEntry } from "../leaderboards-types.ts";
interface Props {
entry: Pick<TeamLeaderboardEntry, "identifier" | "isSkipped">;
season: number;
/** The query args of the leaderboard the client currently shows, so the mutation can update it single-flight. */
queryArgs: LeaderboardsQueryArgs;
}
interface Props {
entry: Pick<TeamLeaderboardEntry, "identifier" | "isSkipped">;
season: number;
/** The query args of the leaderboard the client currently shows, so the mutation can update it single-flight. */
queryArgs: LeaderboardsQueryArgs;
}
let { entry, season, queryArgs }: Props = $props();
let { entry, season, queryArgs }: Props = $props();
const fields = $derived({ season, identifier: entry.identifier });
const fields = $derived({ season, identifier: entry.identifier });
</script>
<Menu>

View File

@@ -1,35 +1,35 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import InfoPopover from "#lib/components/InfoPopover.svelte";
import { hasRole } from "#lib/features/auth/user-state.ts";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
import { m } from "#lib/paraglide/messages.js";
import { teamPage, userPage } from "#lib/utils/urls.ts";
import { TEAM_LEADERBOARD_QUALIFYING_COUNT } from "../leaderboards-constants.ts";
import type { LeaderboardsQueryArgs } from "../leaderboards-schemas.ts";
import type { TeamLeaderboardEntry } from "../leaderboards-types.ts";
import TeamStaffMenu from "./TeamStaffMenu.svelte";
import Avatar from "#lib/components/Avatar.svelte";
import InfoPopover from "#lib/components/InfoPopover.svelte";
import { hasRole } from "#lib/features/auth/user-state.ts";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
import { m } from "#lib/paraglide/messages.js";
import { teamPage, userPage } from "#lib/utils/urls.ts";
import { TEAM_LEADERBOARD_QUALIFYING_COUNT } from "../leaderboards-constants.ts";
import type { LeaderboardsQueryArgs } from "../leaderboards-schemas.ts";
import type { TeamLeaderboardEntry } from "../leaderboards-types.ts";
import TeamStaffMenu from "./TeamStaffMenu.svelte";
interface Props {
entries: TeamLeaderboardEntry[];
season: number;
queryArgs: LeaderboardsQueryArgs;
showQualificationDividers?: boolean;
}
interface Props {
entries: TeamLeaderboardEntry[];
season: number;
queryArgs: LeaderboardsQueryArgs;
showQualificationDividers?: boolean;
}
let {
entries,
season,
queryArgs,
showQualificationDividers: showQualificationDividersProp,
}: Props = $props();
let {
entries,
season,
queryArgs,
showQualificationDividers: showQualificationDividersProp,
}: Props = $props();
const isStaff = $derived(hasRole("STAFF"));
const showStaffActions = $derived(isStaff && queryArgs.type !== "TEAM-ALL");
const isCurrentSeason = $derived(season === Seasons.current()?.nth);
const showQualificationDividers = $derived(
showQualificationDividersProp && isCurrentSeason && entries.length > 20,
);
const isStaff = $derived(hasRole("STAFF"));
const showStaffActions = $derived(isStaff && queryArgs.type !== "TEAM-ALL");
const isCurrentSeason = $derived(season === Seasons.current()?.nth);
const showQualificationDividers = $derived(
showQualificationDividersProp && isCurrentSeason && entries.length > 20,
);
</script>
<div class="table">

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import WeaponImage from "#lib/components/WeaponImage.svelte";
import { topSearchPlayerPage } from "#lib/utils/urls.ts";
import type { XPLeaderboardEntry } from "../leaderboards-types.ts";
import Avatar from "#lib/components/Avatar.svelte";
import WeaponImage from "#lib/components/WeaponImage.svelte";
import { topSearchPlayerPage } from "#lib/utils/urls.ts";
import type { XPLeaderboardEntry } from "../leaderboards-types.ts";
interface Props {
entries: XPLeaderboardEntry[];
}
interface Props {
entries: XPLeaderboardEntry[];
}
let { entries }: Props = $props();
let { entries }: Props = $props();
</script>
<div class="table">

View File

@@ -3,10 +3,10 @@ import type { MainWeaponId } from "@sendou/in-game-lists/types";
import { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import type { Unwrapped } from "@sendou/utils/types";
import { cache, IN_MILLISECONDS, ttl } from "#lib/server/cache.ts";
import * as UserRepository from "../user-page/UserRepository.server.ts";
import { USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN } from "../mmr/mmr-constants.ts";
import * as Seasons from "../mmr/Seasons.ts";
import { freshUserSkills } from "../mmr/tiered.server.ts";
import * as UserRepository from "../user-page/UserRepository.server.ts";
import type {
SeasonPopularUsersWeapon,
UserSPLeaderboardItem,

View File

@@ -5,7 +5,15 @@ import { leaderboardsSearchParams } from "./leaderboards-search-params.ts";
describe("leaderboardsSearchParams", () => {
test("round-trips representative values", () => {
assertRoundTrips(leaderboardsSearchParams, {
type: ["USER", "TEAM", "TEAM-ALL", "USER-SHOOTERS", "XP-ALL", "XP-MODE-SZ", "XP-WEAPON-40"],
type: [
"USER",
"TEAM",
"TEAM-ALL",
"USER-SHOOTERS",
"XP-ALL",
"XP-MODE-SZ",
"XP-WEAPON-40",
],
season: [null, 0, 5],
});
});

View File

@@ -4,11 +4,12 @@ import type {
} from "@sendou/in-game-lists/types";
import type { weaponCategories } from "@sendou/in-game-lists/weapon-ids";
import { logger } from "@sendou/utils/logger";
import { command, query } from "$app/server";
import { getUser, requireUser } from "#lib/features/auth/user.server.ts";
import { requireRole } from "#lib/modules/permissions/guards.server.ts";
import { command, query } from "$app/server";
import * as Seasons from "../mmr/Seasons.ts";
import * as LeaderboardRepository from "./LeaderboardRepository.server.ts";
import { WEAPON_LEADERBOARD_MAX_SIZE } from "./leaderboards-constants.ts";
import {
cachedFullUserLeaderboard,
cachedTeamLeaderboard,
@@ -17,7 +18,6 @@ import {
ownEntryPeek,
shownUserLeaderboard,
} from "./leaderboards-core.server.ts";
import { WEAPON_LEADERBOARD_MAX_SIZE } from "./leaderboards-constants.ts";
import {
leaderboardsQuerySchema,
teamLeaderboardEntrySchema,
@@ -57,7 +57,8 @@ export const getLeaderboards = query(
).slice(0, WEAPON_LEADERBOARD_MAX_SIZE)
: userLeaderboard;
const showOwnEntryPeek = fullUserLeaderboard && !isWeaponLeaderboard && user;
const showOwnEntryPeek =
fullUserLeaderboard && !isWeaponLeaderboard && user;
const xpLeaderboard =
type === "XP-ALL"

View File

@@ -1,30 +1,30 @@
<script lang="ts">
import { Select, SelectItem, SelectItemSection } from "@sendou/components";
import * as Seasons from "./Seasons.ts";
import { Select, SelectItem, SelectItemSection } from "@sendou/components";
import * as Seasons from "./Seasons.ts";
interface Props {
/** Localized word for "season", used both as the select's label and as the prefix of every season's name. */
label: string;
season: number;
onChange: (season: number) => void;
/** If given, seasons it returns `true` for can't be selected. */
isSeasonDisabled?: (season: number) => boolean;
}
interface Props {
/** Localized word for "season", used both as the select's label and as the prefix of every season's name. */
label: string;
season: number;
onChange: (season: number) => void;
/** If given, seasons it returns `true` for can't be selected. */
isSeasonDisabled?: (season: number) => boolean;
}
let { label, season, onChange, isSeasonDisabled }: Props = $props();
let { label, season, onChange, isSeasonDisabled }: Props = $props();
const seasonsByYear = $derived.by(() => {
const grouped = Object.groupBy(Seasons.allStarted(), (seasonNth) =>
Seasons.nthToDateRange(seasonNth).starts.getFullYear(),
);
const seasonsByYear = $derived.by(() => {
const grouped = Object.groupBy(Seasons.allStarted(), (seasonNth) =>
Seasons.nthToDateRange(seasonNth).starts.getFullYear(),
);
return Object.entries(grouped)
.sort(([yearA], [yearB]) => Number(yearB) - Number(yearA))
.map(([year, seasons]) => ({
year,
seasons: (seasons ?? []).sort((a, b) => b - a),
}));
});
return Object.entries(grouped)
.sort(([yearA], [yearB]) => Number(yearB) - Number(yearA))
.map(([year, seasons]) => ({
year,
seasons: (seasons ?? []).sort((a, b) => b - a),
}));
});
</script>
<Select

View File

@@ -1,24 +1,24 @@
<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";
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;
const PEEK_COUNT = 6;
interface Props {
notifications: NotificationRow[];
unseenIds: number[];
onClose?: () => void;
}
interface Props {
notifications: NotificationRow[];
unseenIds: number[];
onClose?: () => void;
}
let { notifications, unseenIds, onClose }: Props = $props();
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);
// 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">

View File

@@ -1,28 +1,28 @@
<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";
import Image from "#lib/components/Image.svelte";
import type { NotificationRow } from "#lib/components/layout/layout-types.ts";
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;
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));
}
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;
});
return message;
});
</script>
<a

View File

@@ -1,6 +1,11 @@
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 {
FRIENDS_PAGE,
SENDOUQ_PAGE,
sendouQMatchPage,
userPage,
} from "#lib/utils/urls.ts";
import type { Notification } from "./notifications-types.ts";
const NEW_TROPHY_PAGE = "/trophies/new";

View File

@@ -1,5 +1,5 @@
import { query } from "$app/server";
import { getUser } from "#lib/features/auth/user.server.ts";
import { query } from "$app/server";
import * as NotificationRepository from "./NotificationRepository.server.ts";
import { NOTIFICATIONS } from "./notifications-constants.ts";

View File

@@ -8,10 +8,14 @@ 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 * as Seasons from "#lib/features/mmr/Seasons.ts";
import { cache, IN_MILLISECONDS, ttl } from "#lib/server/cache.ts";
import { navIconUrl, SENDOUQ_STREAMS_PAGE, tierImageUrl } from "#lib/utils/urls.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";

View File

@@ -29,8 +29,8 @@ import {
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 type { TournamentTierNumber } from "#lib/features/tournament/tournament-types.ts";
import { cache, ttl } from "#lib/server/cache.ts";
import type { CommonUser } from "#lib/server/kysely.ts";
import { dateToDatabaseTimestamp } from "#lib/utils/dates.ts";

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import type { Theme } from "./theme.server.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;
}
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();
let { theme }: Props = $props();
const metaColorScheme = $derived(
theme === "light" ? "light dark" : "dark light",
);
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 = `
// 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'

View File

@@ -1,6 +1,6 @@
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";
import { db } from "#lib/server/db/sql.ts";
async function findCalendarEventIdsByUserId(userId: number): Promise<number[]> {
const rows = await db

View File

@@ -1,5 +1,5 @@
import type { NotNull } from "kysely";
import type { Unwrapped } from "@sendou/utils/types";
import type { NotNull } from "kysely";
import { db } from "#lib/server/db/sql.ts";
import {
commonUserSelect,

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import * as v from "valibot";
import { describe, expect, test } from "vitest";
import * as SearchParams from "./search-params.ts";
import { SP } from "./search-params.ts";
import {

View File

@@ -484,16 +484,13 @@ function deriveScalarBase(schema: AnyValiSchema): ScalarBase | null {
if (schema.type === "boolean") return "boolean";
if (schema.type === "picklist" || schema.type === "enum") {
return uniformTypeOf(
(schema as unknown as { options: unknown[] }).options,
);
return uniformTypeOf((schema as unknown as { options: unknown[] }).options);
}
if (schema.type === "literal") {
return uniformTypeOf([(schema as unknown as { literal: unknown }).literal]);
}
if (schema.type === "union") {
const options = (schema as unknown as { options: AnyValiSchema[] })
.options;
const options = (schema as unknown as { options: AnyValiSchema[] }).options;
const bases = options.map(deriveScalarBase);
if (bases[0] && bases.every((base) => base === bases[0])) {
return bases[0];

View File

@@ -1,7 +1,4 @@
import type {
MainWeaponId,
ModeShort,
} from "@sendou/in-game-lists/types";
import type { MainWeaponId, ModeShort } from "@sendou/in-game-lists/types";
import type {
ColumnType,
GeneratedAlways,
@@ -9,20 +6,20 @@ import type {
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,
XRankPlacementRegion,
} from "#lib/db/tables-json.ts";
import type { CustomTheme } from "#lib/features/theme/theme-types.ts";
import type { AssociationVisibility } from "#lib/features/associations/associations-types.ts";
import type { SkillTeamIdentifier } from "#lib/features/mmr/mmr-utils.ts";
import type { Notification as NotificationValue } from "#lib/features/notifications/notifications-types.ts";
import type { CustomTheme } from "#lib/features/theme/theme-types.ts";
import type {
TournamentSettingsLite,
TournamentStaffRole,
TournamentTierNumber,
} from "#lib/features/tournament/tournament-types.ts";
import type { UnifiedLanguageCode } from "#lib/modules/i18n/languages.ts";
/**

View File

@@ -1,5 +1,4 @@
export const IS_E2E_TEST_RUN =
(typeof import.meta.env !== "undefined" &&
import.meta.env.VITE_E2E_TEST_RUN === "true") ||
(typeof process !== "undefined" &&
process.env.VITE_E2E_TEST_RUN === "true");
(typeof process !== "undefined" && process.env.VITE_E2E_TEST_RUN === "true");

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from "$app/state";
import { page } from "$app/state";
</script>
<main data-testid="error-page">

View File

@@ -1,35 +1,35 @@
<script lang="ts">
import "#lib/styles/fonts.css";
import "#lib/styles/vars.css";
import "#lib/styles/normalize.css";
import "#lib/styles/common.css";
import "#lib/styles/utils.css";
import "#lib/styles/flags.css";
import "nprogress/nprogress.css";
import "#lib/styles/fonts.css";
import "#lib/styles/vars.css";
import "#lib/styles/normalize.css";
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";
import NProgress from "nprogress";
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";
import { navigating, page } from "$app/state";
let { children } = $props();
let { children } = $props();
const theme = $derived(
(page.data as { theme?: "dark" | "light" | null }).theme ?? null,
);
const theme = $derived(
(page.data as { theme?: "dark" | "light" | null }).theme ?? null,
);
NProgress.configure({ parent: `#nprogress-anchor`, showSpinner: false });
NProgress.configure({ parent: "#nprogress-anchor", showSpinner: false });
$effect(() => {
if (navigating.to) {
NProgress.start();
} else {
NProgress.done();
}
});
$effect(() => {
if (navigating.to) {
NProgress.start();
} else {
NProgress.done();
}
});
</script>
<ThemeHead {theme} />

View File

@@ -1,5 +1,4 @@
import { error, redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
import {
AUTH_COOKIE_NAME,
@@ -8,8 +7,14 @@ import {
readSessionCookie,
writeSessionCookie,
} from "#lib/features/auth/session.server.ts";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, url, cookies, locals }) => {
export const POST: RequestHandler = async ({
request,
url,
cookies,
locals,
}) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
const user = locals.user;
const canImpersonate =
@@ -27,11 +32,7 @@ export const POST: RequestHandler = async ({ request, url, cookies, locals }) =>
const session = readSessionCookie(cookies.get(AUTH_COOKIE_NAME)) ?? {};
session[IMPERSONATED_SESSION_KEY] = userId;
cookies.set(
AUTH_COOKIE_NAME,
writeSessionCookie(session),
authCookieOptions,
);
cookies.set(AUTH_COOKIE_NAME, writeSessionCookie(session), authCookieOptions);
const formData = await request.formData().catch(() => null);
const returnTo = formData?.get("returnTo");

View File

@@ -1,5 +1,4 @@
import { redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import {
AUTH_COOKIE_NAME,
authCookieOptions,
@@ -7,15 +6,12 @@ import {
readSessionCookie,
writeSessionCookie,
} from "#lib/features/auth/session.server.ts";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = ({ cookies }) => {
const session = readSessionCookie(cookies.get(AUTH_COOKIE_NAME)) ?? {};
delete session[IMPERSONATED_SESSION_KEY];
cookies.set(
AUTH_COOKIE_NAME,
writeSessionCookie(session),
authCookieOptions,
);
cookies.set(AUTH_COOKIE_NAME, writeSessionCookie(session), authCookieOptions);
redirect(303, "/admin");
};

View File

@@ -1,7 +1,7 @@
import { error, json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = () => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import MetaTags from "#lib/components/MetaTags.svelte";
import Leaderboards from "#lib/features/leaderboards/components/Leaderboards.svelte";
import MetaTags from "#lib/components/MetaTags.svelte";
import Leaderboards from "#lib/features/leaderboards/components/Leaderboards.svelte";
</script>
<MetaTags

View File

@@ -1,9 +1,9 @@
import { error, json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
import { refreshBannedCache } from "#lib/features/ban/banned.server.ts";
import * as Seasons from "#lib/features/mmr/Seasons.ts";
import { cache } from "#lib/server/cache.ts";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {

View File

@@ -1,6 +1,6 @@
import { error, json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
import type { RequestHandler } from "./$types";
// grows as routines are ported with their features (svelte-big-bang.md phase 4+)
const routines = new Map<string, () => Promise<void>>();

View File

@@ -1,11 +1,11 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import {
readSessionCookie,
writeSessionCookie,
} from "#lib/features/auth/session.server.ts";
import { ServerConfig } from "#lib/server/config.ts";
import { IS_E2E_TEST_RUN } from "#lib/utils/e2e.ts";
import type { RequestHandler } from "./$types";
const SIDENAV_COOKIE_NAME = "sidenav";
const TEN_YEARS_IN_SECONDS = 315_360_000;
@@ -14,8 +14,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
const form = new URLSearchParams(await request.text());
const collapsed = form.get("collapsed") === "true";
const session =
readSessionCookie(cookies.get(SIDENAV_COOKIE_NAME)) ?? {};
const session = readSessionCookie(cookies.get(SIDENAV_COOKIE_NAME)) ?? {};
session.collapsed = collapsed;
cookies.set(SIDENAV_COOKIE_NAME, writeSessionCookie(session), {
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,

View File

@@ -1,10 +1,10 @@
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import {
clearTheme,
isTheme,
setTheme,
} from "#lib/features/theme/theme.server.ts";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, cookies }) => {
const form = new URLSearchParams(await request.text());

View File

@@ -12,7 +12,8 @@ export default defineConfig(({ mode }) => ({
project: "./project.inlang",
outdir: "./src/lib/paraglide",
strategy: ["cookie", "preferredLanguage", "baseLocale"],
outputStructure: mode === "production" ? "message-modules" : "locale-modules",
outputStructure:
mode === "production" ? "message-modules" : "locale-modules",
}),
sveltekit({
adapter: adapter(),

View File

@@ -8,6 +8,8 @@
"!apps/web-react/app/db/seed/data/placements.json",
"!apps/web-react/app/features/scanner/tests/fixtures",
"!apps/web-react/public/scanner/**/*",
"!apps/web/**/*",
"!packages/components/**/*",
"!**/build/**/*",
"!**/test-results/**/*",
"!**/playwright-report/**/*",

View File

@@ -1,38 +1,38 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
type ButtonVariant =
| "primary"
| "success"
| "destructive"
| "outlined"
| "outlined-success"
| "outlined-destructive"
| "minimal"
| "minimal-success"
| "minimal-destructive";
type ButtonVariant =
| "primary"
| "success"
| "destructive"
| "outlined"
| "outlined-success"
| "outlined-destructive"
| "minimal"
| "minimal-success"
| "minimal-destructive";
interface Props extends HTMLButtonAttributes {
variant?: ButtonVariant;
size?: "miniscule" | "small" | "medium" | "big";
shape?: "circle" | "square";
icon?: Snippet;
children?: Snippet;
testId?: string;
}
interface Props extends HTMLButtonAttributes {
variant?: ButtonVariant;
size?: "miniscule" | "small" | "medium" | "big";
shape?: "circle" | "square";
icon?: Snippet;
children?: Snippet;
testId?: string;
}
let {
variant,
size,
shape,
icon,
children,
testId,
class: className,
type = "button",
...rest
}: Props = $props();
let {
variant,
size,
shape,
icon,
children,
testId,
class: className,
type = "button",
...rest
}: Props = $props();
</script>
<button

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Snippet } from "svelte";
interface Props {
name: string;
value: string;
checked: boolean;
onChange: (value: string) => void;
children: Snippet;
}
interface Props {
name: string;
value: string;
checked: boolean;
onChange: (value: string) => void;
children: Snippet;
}
let { name, value, checked, onChange, children }: Props = $props();
let { name, value, checked, onChange, children }: Props = $props();
const id = $derived(`chip-radio-${name}-${value}`);
const id = $derived(`chip-radio-${name}-${value}`);
</script>
<input

View File

@@ -1,19 +1,19 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
orientation?: "horizontal" | "vertical";
wrap?: boolean;
class?: string;
}
interface Props {
children: Snippet;
orientation?: "horizontal" | "vertical";
wrap?: boolean;
class?: string;
}
let {
children,
orientation = "horizontal",
wrap = false,
class: className,
}: Props = $props();
let {
children,
orientation = "horizontal",
wrap = false,
class: className,
}: Props = $props();
</script>
<div

View File

@@ -1,125 +1,117 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { setMenuContext, type MenuTriggerProps } from "./menu-context.ts";
import type { Snippet } from "svelte";
import { type MenuTriggerProps, setMenuContext } from "./menu-context.ts";
interface Props {
trigger: Snippet<[MenuTriggerProps]>;
scrolling?: boolean;
opensLeft?: boolean;
popoverClass?: string;
children: Snippet;
}
interface Props {
trigger: Snippet<[MenuTriggerProps]>;
scrolling?: boolean;
opensLeft?: boolean;
popoverClass?: string;
children: Snippet;
}
let {
trigger,
scrolling,
opensLeft,
popoverClass,
children,
}: Props = $props();
let { trigger, scrolling, opensLeft, popoverClass, children }: Props = $props();
let open = $state(false);
let triggerContainer = $state<HTMLSpanElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
let open = $state(false);
let triggerContainer = $state<HTMLSpanElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
function setOpen(next: boolean) {
if (open === next) return;
open = next;
function setOpen(next: boolean) {
if (open === next) return;
open = next;
if (next) {
popoverElement?.showPopover();
positionPopover();
requestAnimationFrame(() => {
focusItem("first");
});
} else {
popoverElement?.hidePopover();
}
}
function positionPopover() {
const triggerElement = triggerContainer?.firstElementChild;
if (!triggerElement || !popoverElement) return;
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
popover.style.top = `${rect.bottom + 8}px`;
const popoverWidth = popover.getBoundingClientRect().width;
const alignedLeft = opensLeft
? rect.left
: rect.right - popoverWidth;
popover.style.left = `${Math.max(8, Math.min(alignedLeft, window.innerWidth - popoverWidth - 8))}px`;
}
function menuItems() {
return [
...(popoverElement?.querySelectorAll<HTMLElement>(
'[role="menuitem"]:not([aria-disabled="true"])',
) ?? []),
];
}
function focusItem(target: "first" | "last" | "next" | "previous") {
const elements = menuItems();
if (elements.length === 0) return;
const activeIndex = elements.findIndex(
(element) => element === document.activeElement,
);
const targetIndex =
target === "first"
? 0
: target === "last"
? elements.length - 1
: target === "next"
? (activeIndex + 1) % elements.length
: (activeIndex - 1 + elements.length) % elements.length;
elements[targetIndex].focus();
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
(triggerContainer?.firstElementChild as HTMLElement | null)?.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
focusItem("next");
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
focusItem("previous");
return;
}
if (event.key === "Home") {
event.preventDefault();
if (next) {
popoverElement?.showPopover();
positionPopover();
requestAnimationFrame(() => {
focusItem("first");
return;
}
if (event.key === "End") {
event.preventDefault();
focusItem("last");
}
});
} else {
popoverElement?.hidePopover();
}
}
setMenuContext({
close() {
setOpen(false);
},
});
function positionPopover() {
const triggerElement = triggerContainer?.firstElementChild;
if (!triggerElement || !popoverElement) return;
const triggerProps: MenuTriggerProps = {
get "aria-expanded"() {
return open;
},
"aria-haspopup": "menu",
onclick: () => setOpen(!open),
};
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
popover.style.top = `${rect.bottom + 8}px`;
const popoverWidth = popover.getBoundingClientRect().width;
const alignedLeft = opensLeft ? rect.left : rect.right - popoverWidth;
popover.style.left = `${Math.max(8, Math.min(alignedLeft, window.innerWidth - popoverWidth - 8))}px`;
}
function menuItems() {
return [
...(popoverElement?.querySelectorAll<HTMLElement>(
'[role="menuitem"]:not([aria-disabled="true"])',
) ?? []),
];
}
function focusItem(target: "first" | "last" | "next" | "previous") {
const elements = menuItems();
if (elements.length === 0) return;
const activeIndex = elements.findIndex(
(element) => element === document.activeElement,
);
const targetIndex =
target === "first"
? 0
: target === "last"
? elements.length - 1
: target === "next"
? (activeIndex + 1) % elements.length
: (activeIndex - 1 + elements.length) % elements.length;
elements[targetIndex].focus();
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
(triggerContainer?.firstElementChild as HTMLElement | null)?.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
focusItem("next");
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
focusItem("previous");
return;
}
if (event.key === "Home") {
event.preventDefault();
focusItem("first");
return;
}
if (event.key === "End") {
event.preventDefault();
focusItem("last");
}
}
setMenuContext({
close() {
setOpen(false);
},
});
const triggerProps: MenuTriggerProps = {
get "aria-expanded"() {
return open;
},
"aria-haspopup": "menu",
onclick: () => setOpen(!open),
};
</script>
<span class="triggerContainer" bind:this={triggerContainer}>

View File

@@ -1,34 +1,34 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getMenuContext } from "./menu-context.ts";
import type { Snippet } from "svelte";
import { getMenuContext } from "./menu-context.ts";
interface Props {
onAction?: () => void;
href?: string;
icon?: Snippet;
isActive?: boolean;
isDestructive?: boolean;
isDisabled?: boolean;
children: Snippet;
}
interface Props {
onAction?: () => void;
href?: string;
icon?: Snippet;
isActive?: boolean;
isDestructive?: boolean;
isDisabled?: boolean;
children: Snippet;
}
let {
onAction,
href,
icon,
isActive,
isDestructive,
isDisabled,
children,
}: Props = $props();
let {
onAction,
href,
icon,
isActive,
isDestructive,
isDisabled,
children,
}: Props = $props();
const menu = getMenuContext();
const menu = getMenuContext();
function act() {
if (isDisabled) return;
menu.close();
onAction?.();
}
function act() {
if (isDisabled) return;
menu.close();
onAction?.();
}
</script>
{#snippet content()}

View File

@@ -1,89 +1,83 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Snippet } from "svelte";
export interface PopoverTriggerProps {
readonly "aria-expanded": boolean;
readonly "aria-haspopup": "dialog";
onclick: () => void;
export interface PopoverTriggerProps {
readonly "aria-expanded": boolean;
readonly "aria-haspopup": "dialog";
onclick: () => void;
}
interface Props {
trigger: Snippet<[PopoverTriggerProps]>;
popoverClass?: string;
onOpenChange?: (isOpen: boolean) => void;
isOpen?: boolean;
children: Snippet;
}
let { trigger, popoverClass, onOpenChange, isOpen, children }: Props = $props();
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = isOpen !== undefined;
let uncontrolledOpen = $state(false);
const open = $derived(isControlled ? Boolean(isOpen) : uncontrolledOpen);
let triggerContainer = $state<HTMLSpanElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
$effect(() => {
if (open) {
popoverElement?.showPopover();
positionPopover();
requestAnimationFrame(() => popoverElement?.focus());
} else {
popoverElement?.hidePopover();
}
});
function setOpen(next: boolean) {
if (!isControlled) {
uncontrolledOpen = next;
}
onOpenChange?.(next);
}
function positionPopover() {
const triggerElement = triggerContainer?.firstElementChild;
if (!triggerElement || !popoverElement) return;
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
const popoverRect = popover.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom - 8;
if (popoverRect.height + 8 > spaceBelow && rect.top > spaceBelow) {
popover.style.top = "auto";
popover.style.bottom = `${window.innerHeight - rect.top + 8}px`;
} else {
popover.style.bottom = "auto";
popover.style.top = `${rect.bottom + 8}px`;
}
interface Props {
trigger: Snippet<[PopoverTriggerProps]>;
popoverClass?: string;
onOpenChange?: (isOpen: boolean) => void;
isOpen?: boolean;
children: Snippet;
const centered = rect.left + rect.width / 2 - popoverRect.width / 2;
popover.style.left = `${Math.max(8, Math.min(centered, window.innerWidth - popoverRect.width - 8))}px`;
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
(triggerContainer?.firstElementChild as HTMLElement | null)?.focus();
}
}
let {
trigger,
popoverClass,
onOpenChange,
isOpen,
children,
}: Props = $props();
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = isOpen !== undefined;
let uncontrolledOpen = $state(false);
const open = $derived(isControlled ? Boolean(isOpen) : uncontrolledOpen);
let triggerContainer = $state<HTMLSpanElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
$effect(() => {
if (open) {
popoverElement?.showPopover();
positionPopover();
requestAnimationFrame(() => popoverElement?.focus());
} else {
popoverElement?.hidePopover();
}
});
function setOpen(next: boolean) {
if (!isControlled) {
uncontrolledOpen = next;
}
onOpenChange?.(next);
}
function positionPopover() {
const triggerElement = triggerContainer?.firstElementChild;
if (!triggerElement || !popoverElement) return;
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
const popoverRect = popover.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom - 8;
if (popoverRect.height + 8 > spaceBelow && rect.top > spaceBelow) {
popover.style.top = "auto";
popover.style.bottom = `${window.innerHeight - rect.top + 8}px`;
} else {
popover.style.bottom = "auto";
popover.style.top = `${rect.bottom + 8}px`;
}
const centered = rect.left + rect.width / 2 - popoverRect.width / 2;
popover.style.left = `${Math.max(8, Math.min(centered, window.innerWidth - popoverRect.width - 8))}px`;
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
(triggerContainer?.firstElementChild as HTMLElement | null)?.focus();
}
}
const triggerProps: PopoverTriggerProps = {
get "aria-expanded"() {
return open;
},
"aria-haspopup": "dialog",
onclick: () => setOpen(!open),
};
const triggerProps: PopoverTriggerProps = {
get "aria-expanded"() {
return open;
},
"aria-haspopup": "dialog",
onclick: () => setOpen(!open),
};
</script>
<span class="triggerContainer" bind:this={triggerContainer}>

View File

@@ -1,260 +1,257 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { setSelectContext } from "./select-context.ts";
import type { Snippet } from "svelte";
import { setSelectContext } from "./select-context.ts";
interface Props {
label?: string;
placeholder?: string;
selectedKey?: string | number | null;
defaultSelectedKey?: string | number;
onSelectionChange?: (key: string | number | null) => void;
onOpenChange?: (isOpen: boolean) => void;
clearable?: boolean;
isDisabled?: boolean;
isRequired?: boolean;
testId?: string;
"aria-label"?: string;
search?: { placeholder?: string };
searchValue?: string;
noResultsText?: string;
clearText?: string;
popoverClass?: string;
/** Trigger content for the selected item; falls back to the item's registered text value. */
valueContent?: Snippet<[string | number]>;
children: Snippet;
}
interface Props {
label?: string;
placeholder?: string;
selectedKey?: string | number | null;
defaultSelectedKey?: string | number;
onSelectionChange?: (key: string | number | null) => void;
onOpenChange?: (isOpen: boolean) => void;
clearable?: boolean;
isDisabled?: boolean;
isRequired?: boolean;
testId?: string;
"aria-label"?: string;
search?: { placeholder?: string };
searchValue?: string;
noResultsText?: string;
clearText?: string;
popoverClass?: string;
/** Trigger content for the selected item; falls back to the item's registered text value. */
valueContent?: Snippet<[string | number]>;
children: Snippet;
}
let {
label,
placeholder,
selectedKey,
defaultSelectedKey,
onSelectionChange,
onOpenChange,
clearable = false,
isDisabled,
isRequired,
testId,
"aria-label": ariaLabel,
search,
searchValue = $bindable(""),
noResultsText = "No results",
clearText = "Clear",
popoverClass,
valueContent,
children,
}: Props = $props();
let {
label,
placeholder,
selectedKey,
defaultSelectedKey,
onSelectionChange,
onOpenChange,
clearable = false,
isDisabled,
isRequired,
testId,
"aria-label": ariaLabel,
search,
searchValue = $bindable(""),
noResultsText = "No results",
clearText = "Clear",
popoverClass,
valueContent,
children,
}: Props = $props();
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = selectedKey !== undefined;
// svelte-ignore state_referenced_locally -- the default seeds the initial value only
let uncontrolledKey = $state<string | number | null>(
defaultSelectedKey ?? null,
);
const currentKey = $derived(
isControlled ? (selectedKey ?? null) : uncontrolledKey,
);
// svelte-ignore state_referenced_locally -- controlled vs. uncontrolled is decided once at mount
const isControlled = selectedKey !== undefined;
// svelte-ignore state_referenced_locally -- the default seeds the initial value only
let uncontrolledKey = $state<string | number | null>(
defaultSelectedKey ?? null,
);
const currentKey = $derived(
isControlled ? (selectedKey ?? null) : uncontrolledKey,
);
let open = $state(false);
let focusedKey = $state<string | number | null>(null);
let open = $state(false);
let focusedKey = $state<string | number | null>(null);
interface RegisteredItem {
element: HTMLElement;
textValue: string;
disabled: boolean;
}
const items = new Map<string | number, RegisteredItem>();
let itemsVersion = $state(0);
interface RegisteredItem {
element: HTMLElement;
textValue: string;
disabled: boolean;
}
const items = new Map<string | number, RegisteredItem>();
let itemsVersion = $state(0);
let triggerElement = $state<HTMLButtonElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
let searchInputElement = $state<HTMLInputElement | null>(null);
let triggerElement = $state<HTMLButtonElement | null>(null);
let popoverElement = $state<HTMLDivElement | null>(null);
let searchInputElement = $state<HTMLInputElement | null>(null);
const selectedText = $derived.by(() => {
itemsVersion;
return currentKey !== null
? items.get(currentKey)?.textValue
: undefined;
});
const selectedText = $derived.by(() => {
itemsVersion;
return currentKey !== null ? items.get(currentKey)?.textValue : undefined;
});
setSelectContext({
get selectedKey() {
return currentKey;
},
get focusedKey() {
return focusedKey;
},
registerItem(key, element, options) {
items.set(key, { element, ...options });
setSelectContext({
get selectedKey() {
return currentKey;
},
get focusedKey() {
return focusedKey;
},
registerItem(key, element, options) {
items.set(key, { element, ...options });
itemsVersion++;
return () => {
items.delete(key);
itemsVersion++;
return () => {
items.delete(key);
itemsVersion++;
};
},
select(key) {
commitSelection(key);
},
setFocusedKey(key) {
focusedKey = key;
},
});
};
},
select(key) {
commitSelection(key);
},
setFocusedKey(key) {
focusedKey = key;
},
});
function commitSelection(key: string | number | null) {
if (!isControlled) {
uncontrolledKey = key;
}
onSelectionChange?.(key);
function commitSelection(key: string | number | null) {
if (!isControlled) {
uncontrolledKey = key;
}
onSelectionChange?.(key);
setOpen(false);
triggerElement?.focus();
}
function setOpen(next: boolean) {
if (open === next) return;
open = next;
onOpenChange?.(next);
if (next) {
popoverElement?.showPopover();
positionPopover();
focusedKey = currentKey ?? orderedKeys(false)[0] ?? null;
requestAnimationFrame(() => {
if (search) {
searchInputElement?.focus();
} else {
popoverElement?.focus();
}
scrollFocusedIntoView();
});
} else {
popoverElement?.hidePopover();
searchValue = "";
focusedKey = null;
}
}
function positionPopover() {
if (!triggerElement || !popoverElement) return;
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
popover.style.width = `${rect.width}px`;
popover.style.left = `${rect.left}px`;
const maxHeight = 300;
const spaceBelow = window.innerHeight - rect.bottom - 8;
if (spaceBelow < 150) {
popover.style.top = "auto";
popover.style.bottom = `${window.innerHeight - rect.top + 4}px`;
popover.style.maxHeight = `${Math.min(maxHeight, rect.top - 8)}px`;
} else {
popover.style.bottom = "auto";
popover.style.top = `${rect.bottom + 4}px`;
popover.style.maxHeight = `${Math.min(maxHeight, spaceBelow)}px`;
}
}
function orderedKeys(visibleOnly = true) {
void visibleOnly;
return [...items.entries()]
.filter(([, item]) => !item.disabled)
.sort(([, a], [, b]) =>
a.element.compareDocumentPosition(b.element) &
Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1,
)
.map(([key]) => key);
}
function moveFocus(direction: "next" | "previous" | "first" | "last") {
const keys = orderedKeys();
if (keys.length === 0) return;
const currentIndex = focusedKey === null ? -1 : keys.indexOf(focusedKey);
const targetIndex =
direction === "first"
? 0
: direction === "last"
? keys.length - 1
: direction === "next"
? Math.min(currentIndex + 1, keys.length - 1)
: Math.max(currentIndex - 1, 0);
focusedKey = keys[targetIndex];
scrollFocusedIntoView();
}
function scrollFocusedIntoView() {
if (focusedKey === null) return;
items.get(focusedKey)?.element.scrollIntoView({ block: "nearest" });
}
function onTriggerKeydown(event: KeyboardEvent) {
if (
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
setOpen(true);
}
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerElement?.focus();
return;
}
function setOpen(next: boolean) {
if (open === next) return;
open = next;
onOpenChange?.(next);
if (next) {
popoverElement?.showPopover();
positionPopover();
focusedKey = currentKey ?? orderedKeys(false)[0] ?? null;
requestAnimationFrame(() => {
if (search) {
searchInputElement?.focus();
} else {
popoverElement?.focus();
}
scrollFocusedIntoView();
});
} else {
popoverElement?.hidePopover();
searchValue = "";
focusedKey = null;
if (event.key === "ArrowDown") {
event.preventDefault();
moveFocus("next");
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveFocus("previous");
return;
}
if (event.key === "Home" && !search) {
event.preventDefault();
moveFocus("first");
return;
}
if (event.key === "End" && !search) {
event.preventDefault();
moveFocus("last");
return;
}
if (event.key === "Enter") {
event.preventDefault();
if (focusedKey !== null && !items.get(focusedKey)?.disabled) {
commitSelection(focusedKey);
}
}
}
function positionPopover() {
if (!triggerElement || !popoverElement) return;
const rect = triggerElement.getBoundingClientRect();
const popover = popoverElement;
popover.style.width = `${rect.width}px`;
popover.style.left = `${rect.left}px`;
const maxHeight = 300;
const spaceBelow = window.innerHeight - rect.bottom - 8;
if (spaceBelow < 150) {
popover.style.top = "auto";
popover.style.bottom = `${window.innerHeight - rect.top + 4}px`;
popover.style.maxHeight = `${Math.min(maxHeight, rect.top - 8)}px`;
} else {
popover.style.bottom = "auto";
popover.style.top = `${rect.bottom + 4}px`;
popover.style.maxHeight = `${Math.min(maxHeight, spaceBelow)}px`;
}
function onPopoverToggle(event: Event) {
const toggleEvent = event as ToggleEvent;
if (toggleEvent.newState === "closed" && open) {
open = false;
onOpenChange?.(false);
searchValue = "";
focusedKey = null;
}
}
function orderedKeys(visibleOnly = true) {
void visibleOnly;
return [...items.entries()]
.filter(([, item]) => !item.disabled)
.sort(([, a], [, b]) =>
a.element.compareDocumentPosition(b.element) &
Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1,
)
.map(([key]) => key);
}
const hasVisibleItems = $derived.by(() => {
itemsVersion;
return items.size > 0;
});
function moveFocus(direction: "next" | "previous" | "first" | "last") {
const keys = orderedKeys();
if (keys.length === 0) return;
const currentIndex =
focusedKey === null ? -1 : keys.indexOf(focusedKey);
const targetIndex =
direction === "first"
? 0
: direction === "last"
? keys.length - 1
: direction === "next"
? Math.min(currentIndex + 1, keys.length - 1)
: Math.max(currentIndex - 1, 0);
focusedKey = keys[targetIndex];
scrollFocusedIntoView();
}
function scrollFocusedIntoView() {
if (focusedKey === null) return;
items.get(focusedKey)?.element.scrollIntoView({ block: "nearest" });
}
function onTriggerKeydown(event: KeyboardEvent) {
if (
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter" ||
event.key === " "
) {
event.preventDefault();
setOpen(true);
}
}
function onPopoverKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
triggerElement?.focus();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
moveFocus("next");
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveFocus("previous");
return;
}
if (event.key === "Home" && !search) {
event.preventDefault();
moveFocus("first");
return;
}
if (event.key === "End" && !search) {
event.preventDefault();
moveFocus("last");
return;
}
if (event.key === "Enter") {
event.preventDefault();
if (focusedKey !== null && !items.get(focusedKey)?.disabled) {
commitSelection(focusedKey);
}
}
}
function onPopoverToggle(event: Event) {
const toggleEvent = event as ToggleEvent;
if (toggleEvent.newState === "closed" && open) {
open = false;
onOpenChange?.(false);
searchValue = "";
focusedKey = null;
}
}
const hasVisibleItems = $derived.by(() => {
itemsVersion;
return items.size > 0;
});
const uid = $props.id();
const labelId = $derived(label ? `${uid}-select-label` : undefined);
const uid = $props.id();
const labelId = $derived(label ? `${uid}-select-label` : undefined);
</script>
<svelte:window

View File

@@ -1,27 +1,27 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getSelectContext } from "./select-context.ts";
import type { Snippet } from "svelte";
import { getSelectContext } from "./select-context.ts";
interface Props {
id: string | number;
textValue: string;
isDisabled?: boolean;
children: Snippet;
}
interface Props {
id: string | number;
textValue: string;
isDisabled?: boolean;
children: Snippet;
}
let { id, textValue, isDisabled = false, children }: Props = $props();
let { id, textValue, isDisabled = false, children }: Props = $props();
const select = getSelectContext();
const select = getSelectContext();
const isSelected = $derived(select.selectedKey === id);
const isFocused = $derived(select.focusedKey === id);
const isSelected = $derived(select.selectedKey === id);
const isFocused = $derived(select.focusedKey === id);
function register(element: HTMLElement) {
return select.registerItem(id, element, {
textValue,
disabled: isDisabled,
});
}
function register(element: HTMLElement) {
return select.registerItem(id, element, {
textValue,
disabled: isDisabled,
});
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -- keyboard handled by the listbox popover -->

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { Snippet } from "svelte";
interface Props {
heading: string;
headingImg?: Snippet;
class?: string;
children: Snippet;
}
interface Props {
heading: string;
headingImg?: Snippet;
class?: string;
children: Snippet;
}
let { heading, headingImg, class: className, children }: Props = $props();
let { heading, headingImg, class: className, children }: Props = $props();
</script>
<div role="group" aria-label={heading}>

View File

@@ -1,48 +1,46 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getTabsContext } from "./tabs-context.ts";
import type { Snippet } from "svelte";
import { getTabsContext } from "./tabs-context.ts";
interface Props {
id: string;
icon?: Snippet;
number?: number;
/** Render a warning-colored alert icon to draw attention to this tab. */
alert?: boolean;
children?: Snippet;
}
interface Props {
id: string;
icon?: Snippet;
number?: number;
/** Render a warning-colored alert icon to draw attention to this tab. */
alert?: boolean;
children?: Snippet;
}
let { id, icon, number, alert, children }: Props = $props();
let { id, icon, number, alert, children }: Props = $props();
const tabs = getTabsContext();
const tabs = getTabsContext();
const selected = $derived(tabs.selectedKey === id);
const selected = $derived(tabs.selectedKey === id);
function onkeydown(event: KeyboardEvent) {
const nextKey =
tabs.orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const previousKey =
tabs.orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
function onkeydown(event: KeyboardEvent) {
const nextKey = tabs.orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const previousKey = tabs.orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
const direction =
event.key === nextKey
? ("next" as const)
: event.key === previousKey
? ("previous" as const)
: event.key === "Home"
? ("first" as const)
: event.key === "End"
? ("last" as const)
: null;
const direction =
event.key === nextKey
? ("next" as const)
: event.key === previousKey
? ("previous" as const)
: event.key === "Home"
? ("first" as const)
: event.key === "End"
? ("last" as const)
: null;
if (!direction) return;
if (!direction) return;
event.preventDefault();
tabs.moveFocus(id, direction);
}
event.preventDefault();
tabs.moveFocus(id, direction);
}
function registerTab(element: HTMLElement) {
return tabs.registerTab(id, element);
}
function registerTab(element: HTMLElement) {
return tabs.registerTab(id, element);
}
</script>
<div

View File

@@ -1,23 +1,18 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getTabsContext } from "./tabs-context.ts";
import type { Snippet } from "svelte";
import { getTabsContext } from "./tabs-context.ts";
interface Props {
sticky?: boolean;
/** Should tabs take 100% width with equal distribution? */
fullWidth?: boolean;
"aria-label"?: string;
children: Snippet;
}
interface Props {
sticky?: boolean;
/** Should tabs take 100% width with equal distribution? */
fullWidth?: boolean;
"aria-label"?: string;
children: Snippet;
}
let {
sticky,
fullWidth,
"aria-label": ariaLabel,
children,
}: Props = $props();
let { sticky, fullWidth, "aria-label": ariaLabel, children }: Props = $props();
const tabs = getTabsContext();
const tabs = getTabsContext();
</script>
<div class="tabListContainer scrollbar">

View File

@@ -1,18 +1,18 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getTabsContext } from "./tabs-context.ts";
import type { Snippet } from "svelte";
import { getTabsContext } from "./tabs-context.ts";
interface Props {
id: string;
class?: string;
children: Snippet;
}
interface Props {
id: string;
class?: string;
children: Snippet;
}
let { id, class: className, children }: Props = $props();
let { id, class: className, children }: Props = $props();
const tabs = getTabsContext();
const tabs = getTabsContext();
const selected = $derived(tabs.selectedKey === id);
const selected = $derived(tabs.selectedKey === id);
</script>
{#if selected}

Some files were not shown because too many files have changed in this diff Show More