mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 23:27:55 -05:00
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:
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
2
apps/web/src/app.d.ts
vendored
2
apps/web/src/app.d.ts
vendored
@@ -11,5 +11,3 @@ declare global {
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { class: className }: { class?: string } = $props();
|
||||
let { class: className }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { class: className }: { class?: string } = $props();
|
||||
let { class: className }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { class: className }: { class?: string } = $props();
|
||||
let { class: className }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { class: className }: { class?: string } = $props();
|
||||
let { class: className }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
findOwnGroupLite,
|
||||
FULL_GROUP_SIZE,
|
||||
findOwnGroupLite,
|
||||
groupExpiryStatus,
|
||||
type LiteGroup,
|
||||
} from "#lib/features/sendouq/q-groups.server.ts";
|
||||
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { page } from "$app/state";
|
||||
</script>
|
||||
|
||||
<main data-testid="error-page">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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/**/*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user