From 2aaf937ff94c2397c67596dcb332fda4f87e93c0 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sun, 16 Aug 2026 11:48:15 +0300
Subject: [PATCH] Leaderboards page + Svelte UI kit foundations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- packages/components (@sendou/components): Button, ChipRadio(+Group), Tabs set (handrolled ARIA tabs), Menu, Popover, Select stack (search, sections, keyboard nav) — native popover API instead of react-aria
- app components: Main, Image/WeaponImage/ModeImage/TierImage, Avatar (canvas identicon port), Flag, Placement, InfoPopover, MetaTags, WeaponSelect, SeasonSelect
- leaderboards feature UI: Leaderboards + PlayersTable/TeamTable/XPTable/OwnEntryPeek/TeamStaffMenu/TopTenPlayer, scoped styles ported from top-search.module.css
- root layout serializes the lean user; hasRole() reads it from page.data
- skip/unskip commands go single-flight via .updates() on the client-held query instance
- SSR verified against the differ seed database
---
.vscode/settings.json | 3 +-
apps/web/package.json | 2 +
apps/web/src/lib/components/Avatar.svelte | 192 ++++++
apps/web/src/lib/components/Flag.svelte | 20 +
apps/web/src/lib/components/Image.svelte | 48 ++
.../web/src/lib/components/InfoPopover.svelte | 55 ++
apps/web/src/lib/components/Main.svelte | 66 ++
apps/web/src/lib/components/MetaTags.svelte | 46 ++
apps/web/src/lib/components/ModeImage.svelte | 22 +
apps/web/src/lib/components/Placement.svelte | 63 ++
apps/web/src/lib/components/TierImage.svelte | 48 ++
.../web/src/lib/components/WeaponImage.svelte | 61 ++
.../src/lib/components/WeaponSelect.svelte | 187 ++++++
apps/web/src/lib/features/auth/user-state.ts | 18 +
.../features/leaderboards/TopTenPlayer.svelte | 108 ++++
.../components/Leaderboards.svelte | 350 +++++++++++
.../components/OwnEntryPeek.svelte | 53 ++
.../components/PlayersTable.svelte | 56 ++
.../components/TeamStaffMenu.svelte | 49 ++
.../leaderboards/components/TeamTable.svelte | 66 ++
.../leaderboards/components/XPTable.svelte | 41 ++
.../leaderboards/leaderboards-types.ts | 17 +
.../src/lib/features/mmr/SeasonSelect.svelte | 50 ++
apps/web/src/lib/modules/i18n/messages.ts | 45 ++
apps/web/src/lib/utils/i18n.ts | 84 +++
apps/web/src/lib/utils/strings.ts | 26 +
apps/web/src/lib/utils/urls.ts | 84 +++
apps/web/src/routes/+layout.server.ts | 25 +
apps/web/src/routes/leaderboards/+page.svelte | 13 +-
packages/components/README.md | 13 +
packages/components/package.json | 21 +
packages/components/src/Button.svelte | 214 +++++++
packages/components/src/ChipRadio.svelte | 67 ++
packages/components/src/ChipRadioGroup.svelte | 44 ++
packages/components/src/Menu.svelte | 181 ++++++
packages/components/src/MenuItem.svelte | 112 ++++
packages/components/src/Popover.svelte | 132 ++++
packages/components/src/Select.svelte | 579 ++++++++++++++++++
packages/components/src/SelectItem.svelte | 72 +++
.../components/src/SelectItemSection.svelte | 51 ++
packages/components/src/Tab.svelte | 83 +++
packages/components/src/TabList.svelte | 32 +
packages/components/src/TabPanel.svelte | 27 +
packages/components/src/Tabs.svelte | 285 +++++++++
packages/components/src/index.ts | 14 +
packages/components/src/menu-context.ts | 25 +
packages/components/src/select-context.ts | 27 +
packages/components/src/tabs-context.ts | 23 +
pnpm-lock.yaml | 24 +
49 files changed, 3922 insertions(+), 2 deletions(-)
create mode 100644 apps/web/src/lib/components/Avatar.svelte
create mode 100644 apps/web/src/lib/components/Flag.svelte
create mode 100644 apps/web/src/lib/components/Image.svelte
create mode 100644 apps/web/src/lib/components/InfoPopover.svelte
create mode 100644 apps/web/src/lib/components/Main.svelte
create mode 100644 apps/web/src/lib/components/MetaTags.svelte
create mode 100644 apps/web/src/lib/components/ModeImage.svelte
create mode 100644 apps/web/src/lib/components/Placement.svelte
create mode 100644 apps/web/src/lib/components/TierImage.svelte
create mode 100644 apps/web/src/lib/components/WeaponImage.svelte
create mode 100644 apps/web/src/lib/components/WeaponSelect.svelte
create mode 100644 apps/web/src/lib/features/auth/user-state.ts
create mode 100644 apps/web/src/lib/features/leaderboards/TopTenPlayer.svelte
create mode 100644 apps/web/src/lib/features/leaderboards/components/Leaderboards.svelte
create mode 100644 apps/web/src/lib/features/leaderboards/components/OwnEntryPeek.svelte
create mode 100644 apps/web/src/lib/features/leaderboards/components/PlayersTable.svelte
create mode 100644 apps/web/src/lib/features/leaderboards/components/TeamStaffMenu.svelte
create mode 100644 apps/web/src/lib/features/leaderboards/components/TeamTable.svelte
create mode 100644 apps/web/src/lib/features/leaderboards/components/XPTable.svelte
create mode 100644 apps/web/src/lib/features/leaderboards/leaderboards-types.ts
create mode 100644 apps/web/src/lib/features/mmr/SeasonSelect.svelte
create mode 100644 apps/web/src/lib/modules/i18n/messages.ts
create mode 100644 apps/web/src/lib/utils/i18n.ts
create mode 100644 apps/web/src/lib/utils/strings.ts
create mode 100644 apps/web/src/lib/utils/urls.ts
create mode 100644 apps/web/src/routes/+layout.server.ts
create mode 100644 packages/components/README.md
create mode 100644 packages/components/package.json
create mode 100644 packages/components/src/Button.svelte
create mode 100644 packages/components/src/ChipRadio.svelte
create mode 100644 packages/components/src/ChipRadioGroup.svelte
create mode 100644 packages/components/src/Menu.svelte
create mode 100644 packages/components/src/MenuItem.svelte
create mode 100644 packages/components/src/Popover.svelte
create mode 100644 packages/components/src/Select.svelte
create mode 100644 packages/components/src/SelectItem.svelte
create mode 100644 packages/components/src/SelectItemSection.svelte
create mode 100644 packages/components/src/Tab.svelte
create mode 100644 packages/components/src/TabList.svelte
create mode 100644 packages/components/src/TabPanel.svelte
create mode 100644 packages/components/src/Tabs.svelte
create mode 100644 packages/components/src/index.ts
create mode 100644 packages/components/src/menu-context.ts
create mode 100644 packages/components/src/select-context.ts
create mode 100644 packages/components/src/tabs-context.ts
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 60825dbfa..33049ebae 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,5 +3,6 @@
"editor.codeActionsOnSave": {
"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "never"
- }
+ },
+ "js/ts.experimental.useTsgo": false
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 5c4d5591e..bfc652c19 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -19,6 +19,8 @@
},
"dependencies": {
"@epic-web/cachified": "5.6.3",
+ "@lucide/svelte": "0.544.0",
+ "@sendou/components": "workspace:*",
"@sendou/in-game-lists": "workspace:*",
"@sendou/utils": "workspace:*",
"date-fns": "4.4.0",
diff --git a/apps/web/src/lib/components/Avatar.svelte b/apps/web/src/lib/components/Avatar.svelte
new file mode 100644
index 000000000..3c4213648
--- /dev/null
+++ b/apps/web/src/lib/components/Avatar.svelte
@@ -0,0 +1,192 @@
+
+
+
+
+
+
{
+ isErrored = true;
+ }}
+ {@attach checkAlreadyErrored}
+ />
+
+
+
diff --git a/apps/web/src/lib/components/Flag.svelte b/apps/web/src/lib/components/Flag.svelte
new file mode 100644
index 000000000..ff90f5502
--- /dev/null
+++ b/apps/web/src/lib/components/Flag.svelte
@@ -0,0 +1,20 @@
+
+
+
diff --git a/apps/web/src/lib/components/Image.svelte b/apps/web/src/lib/components/Image.svelte
new file mode 100644
index 000000000..ff5ae27d4
--- /dev/null
+++ b/apps/web/src/lib/components/Image.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+
+
diff --git a/apps/web/src/lib/components/InfoPopover.svelte b/apps/web/src/lib/components/InfoPopover.svelte
new file mode 100644
index 000000000..abf074f44
--- /dev/null
+++ b/apps/web/src/lib/components/InfoPopover.svelte
@@ -0,0 +1,55 @@
+
+
+
+ {#snippet trigger(triggerProps)}
+
+ ?
+
+ {/snippet}
+ {@render children()}
+
+
+
diff --git a/apps/web/src/lib/components/Main.svelte b/apps/web/src/lib/components/Main.svelte
new file mode 100644
index 000000000..26f043cea
--- /dev/null
+++ b/apps/web/src/lib/components/Main.svelte
@@ -0,0 +1,66 @@
+
+
+
+ {@render children()}
+
+
+
diff --git a/apps/web/src/lib/components/MetaTags.svelte b/apps/web/src/lib/components/MetaTags.svelte
new file mode 100644
index 000000000..c45800185
--- /dev/null
+++ b/apps/web/src/lib/components/MetaTags.svelte
@@ -0,0 +1,46 @@
+
+
+
+ {fullTitle}
+
+ {#if truncatedDescription}
+
+
+ {/if}
+
+
+
+
+ {#if !image}
+
+
+ {:else if image.dimensions}
+
+
+ {/if}
+
diff --git a/apps/web/src/lib/components/ModeImage.svelte b/apps/web/src/lib/components/ModeImage.svelte
new file mode 100644
index 000000000..ddaf7979e
--- /dev/null
+++ b/apps/web/src/lib/components/ModeImage.svelte
@@ -0,0 +1,22 @@
+
+
+
diff --git a/apps/web/src/lib/components/Placement.svelte b/apps/web/src/lib/components/Placement.svelte
new file mode 100644
index 000000000..e4b75538a
--- /dev/null
+++ b/apps/web/src/lib/components/Placement.svelte
@@ -0,0 +1,63 @@
+
+
+{#if !iconPath}
+ {placement}{#if isSuperscript}{ordinalSuffixText} {:else}{ordinalSuffixText}{/if}
+{:else}
+
+{/if}
diff --git a/apps/web/src/lib/components/TierImage.svelte b/apps/web/src/lib/components/TierImage.svelte
new file mode 100644
index 000000000..f87913e0e
--- /dev/null
+++ b/apps/web/src/lib/components/TierImage.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+ {#if tier.isPlus}
+
+ {/if}
+
+
+
diff --git a/apps/web/src/lib/components/WeaponImage.svelte b/apps/web/src/lib/components/WeaponImage.svelte
new file mode 100644
index 000000000..cc3727914
--- /dev/null
+++ b/apps/web/src/lib/components/WeaponImage.svelte
@@ -0,0 +1,61 @@
+
+
+
diff --git a/apps/web/src/lib/components/WeaponSelect.svelte b/apps/web/src/lib/components/WeaponSelect.svelte
new file mode 100644
index 000000000..13bc5da92
--- /dev/null
+++ b/apps/web/src/lib/components/WeaponSelect.svelte
@@ -0,0 +1,187 @@
+
+
+
+ {#snippet valueContent(selectedKey)}
+
+
+
+ {mainWeaponName(Number(selectedKey))}
+
+
+ {/snippet}
+ {#each visibleCategories as category, idx (category.name)}
+
+ {#snippet headingImg()}
+ {#if !category.isQuickSelect}
+
+ {/if}
+ {/snippet}
+ {#each category.items as item (item.id)}
+
+
+
+
+ {item.name}
+
+
+
+ {/each}
+
+ {/each}
+
+
+
diff --git a/apps/web/src/lib/features/auth/user-state.ts b/apps/web/src/lib/features/auth/user-state.ts
new file mode 100644
index 000000000..183830712
--- /dev/null
+++ b/apps/web/src/lib/features/auth/user-state.ts
@@ -0,0 +1,18 @@
+import { page } from "$app/state";
+import type { Role } from "#lib/modules/permissions/types.ts";
+import type { AuthenticatedUser } from "./user-types.ts";
+
+/** The logged in user as serialized to the client by the root layout. */
+export type ClientUser = Omit;
+
+/** The logged in user, or `undefined` when logged out. Reactive to navigation. */
+export function loggedInUser(): ClientUser | undefined {
+ return (page.data as { user?: ClientUser }).user;
+}
+
+/** Whether the logged in user has the given global role. Always `false` when logged out. */
+export function hasRole(role: Role): boolean {
+ const user = loggedInUser();
+ if (!user) return false;
+ return user.roles.includes(role);
+}
diff --git a/apps/web/src/lib/features/leaderboards/TopTenPlayer.svelte b/apps/web/src/lib/features/leaderboards/TopTenPlayer.svelte
new file mode 100644
index 000000000..a6d8c45e3
--- /dev/null
+++ b/apps/web/src/lib/features/leaderboards/TopTenPlayer.svelte
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
3 ? "margin-block-end: -4px" : undefined}
+ >
+ {#if placement <= 3}
+
+ {/if}
+
place
+
+ {#if !small}
+
+
+ {data.name}
+
+
+ {power}
+
+ {/if}
+
+
+
+
diff --git a/apps/web/src/lib/features/leaderboards/components/Leaderboards.svelte b/apps/web/src/lib/features/leaderboards/components/Leaderboards.svelte
new file mode 100644
index 000000000..c9e3c9193
--- /dev/null
+++ b/apps/web/src/lib/features/leaderboards/components/Leaderboards.svelte
@@ -0,0 +1,350 @@
+
+
+
+
+
+
+
+ {#snippet icon()}
+
+ {/snippet}
+ {m.common_leaderboard_tabs_players()}
+
+
+ {#snippet icon()}
+
+ {/snippet}
+ {m.common_leaderboard_tabs_teams()}
+
+
+ {#snippet icon()}
+
+ {/snippet}
+ {m.common_leaderboard_tabs_xp()}
+
+
+
+
+ params.set({ season })}
+ />
+
+ params.set({ type: "USER" })}
+ >
+ {m.common_leaderboard_filter_all()}
+
+ {#each weaponCategories as category (category.name)}
+ params.set({ type: `USER-${category.name}` })}
+ >
+
+
+ {weaponCategoryName(category.name)}
+
+
+ {/each}
+
+
+
+
+
+ params.set({ season })}
+ />
+
+ params.set({ type: "TEAM" })}
+ >
+ {m.common_leaderboard_teams_best()}
+
+ params.set({ type: "TEAM-ALL" })}
+ >
+ {m.common_leaderboard_teams_all()}
+
+
+
+
+
+
+
+ params.set({ type: "XP-ALL" })}
+ >
+ {m.common_leaderboard_filter_allModes()}
+
+ {#each rankedModesShort as mode (mode)}
+ params.set({ type: `XP-MODE-${mode}` })}
+ >
+
+
+ {modeLongName(mode)}
+
+
+ {/each}
+
+
+ params.set({
+ type: weaponId === null ? "XP-ALL" : `XP-WEAPON-${weaponId}`,
+ })}
+ />
+
+
+
+
+ {#if showTopTen}
+
+ {#each data.userLeaderboard!.slice(0, 10) as entry, i (`${entry.id}-${data.season}`)}
+
+
+
+ {/each}
+
+ {/if}
+
+ {#if data.ownEntryPeek}
+
+ {/if}
+
+ {#if data.userLeaderboard}
+
+ {/if}
+ {#if data.teamLeaderboard}
+
+ {/if}
+ {#if data.xpLeaderboard}
+
+ {/if}
+
+ {#if renderNoEntries}
+
+ {data.userLeaderboard
+ ? m.common_leaderboard_noPlayers()
+ : m.common_leaderboard_noTeams()}
+
+ {/if}
+
+ {#if !data.xpLeaderboard && data.season === Seasons.current()?.nth}
+
+ {m.common_leaderboard_updateInfo()}
+
+ {/if}
+
+
+
+
diff --git a/apps/web/src/lib/features/leaderboards/components/OwnEntryPeek.svelte b/apps/web/src/lib/features/leaderboards/components/OwnEntryPeek.svelte
new file mode 100644
index 000000000..fdb21a6c6
--- /dev/null
+++ b/apps/web/src/lib/features/leaderboards/components/OwnEntryPeek.svelte
@@ -0,0 +1,53 @@
+
+
+
+ {#if entry.firstOfTier}
+
+ {/if}
+
+ {#if nextTier}
+
+ {nextTier.name}{nextTier.isPlus ? "+" : ""} @ {ordinalToSp(
+ nextTier.neededOrdinal!,
+ )}SP
+
+ {/if}
+
diff --git a/apps/web/src/lib/features/leaderboards/components/PlayersTable.svelte b/apps/web/src/lib/features/leaderboards/components/PlayersTable.svelte
new file mode 100644
index 000000000..98334af7f
--- /dev/null
+++ b/apps/web/src/lib/features/leaderboards/components/PlayersTable.svelte
@@ -0,0 +1,56 @@
+
+
+
diff --git a/apps/web/src/lib/features/leaderboards/components/TeamStaffMenu.svelte b/apps/web/src/lib/features/leaderboards/components/TeamStaffMenu.svelte
new file mode 100644
index 000000000..23d3c91fa
--- /dev/null
+++ b/apps/web/src/lib/features/leaderboards/components/TeamStaffMenu.svelte
@@ -0,0 +1,49 @@
+
+
+
+ {#snippet trigger(triggerProps)}
+
+ {#snippet icon()} {/snippet}
+
+ {/snippet}
+ {#if entry.isSkipped}
+ unskipTeam(fields).updates(getLeaderboards(queryArgs))}
+ >
+ {#snippet icon()} {/snippet}
+ Unskip
+
+ {:else}
+ skipTeam(fields).updates(getLeaderboards(queryArgs))}
+ >
+ {#snippet icon()} {/snippet}
+ Skip
+
+ {/if}
+
diff --git a/apps/web/src/lib/features/leaderboards/components/TeamTable.svelte b/apps/web/src/lib/features/leaderboards/components/TeamTable.svelte
new file mode 100644
index 000000000..949d522cb
--- /dev/null
+++ b/apps/web/src/lib/features/leaderboards/components/TeamTable.svelte
@@ -0,0 +1,66 @@
+
+
+
+ {#each entries as entry (entry.entryId)}
+
+
+
{entry.placementRank}
+ {#if entry.team?.avatarUrl}
+
+
+
+ {/if}
+
+ {#each entry.members as member, i (member.id)}
+
{member.username} {i !== entry.members.length - 1 ? ", " : ""}
+ {/each}
+
+
{entry.power.toFixed(2)}
+ {#if showStaffActions}
+
+ {/if}
+
+
+ {#if entry.placementRank === TEAM_LEADERBOARD_QUALIFYING_COUNT && showQualificationDividers}
+
+ {m.common_leaderboard_qualification()}
+
+ {m.common_leaderboard_qualification_info()}
+
+
+ {/if}
+ {/each}
+
diff --git a/apps/web/src/lib/features/leaderboards/components/XPTable.svelte b/apps/web/src/lib/features/leaderboards/components/XPTable.svelte
new file mode 100644
index 000000000..6052043c0
--- /dev/null
+++ b/apps/web/src/lib/features/leaderboards/components/XPTable.svelte
@@ -0,0 +1,41 @@
+
+
+
diff --git a/apps/web/src/lib/features/leaderboards/leaderboards-types.ts b/apps/web/src/lib/features/leaderboards/leaderboards-types.ts
new file mode 100644
index 000000000..9103e1a6b
--- /dev/null
+++ b/apps/web/src/lib/features/leaderboards/leaderboards-types.ts
@@ -0,0 +1,17 @@
+import type { getLeaderboards } from "./leaderboards.remote.ts";
+
+export type LeaderboardsData = Awaited>;
+
+export type UserLeaderboardEntry = NonNullable<
+ LeaderboardsData["userLeaderboard"]
+>[number];
+
+export type TeamLeaderboardEntry = NonNullable<
+ LeaderboardsData["teamLeaderboard"]
+>[number];
+
+export type XPLeaderboardEntry = NonNullable<
+ LeaderboardsData["xpLeaderboard"]
+>[number];
+
+export type OwnEntryPeekData = NonNullable;
diff --git a/apps/web/src/lib/features/mmr/SeasonSelect.svelte b/apps/web/src/lib/features/mmr/SeasonSelect.svelte
new file mode 100644
index 000000000..edd945a8a
--- /dev/null
+++ b/apps/web/src/lib/features/mmr/SeasonSelect.svelte
@@ -0,0 +1,50 @@
+
+
+ {
+ if (seasonNth !== null) onChange(Number(seasonNth));
+ }}
+>
+ {#each seasonsByYear as { year, seasons } (year)}
+
+ {#each seasons as seasonNth (seasonNth)}
+
+ {`${label} ${seasonNth}`}
+
+ {/each}
+
+ {/each}
+
diff --git a/apps/web/src/lib/modules/i18n/messages.ts b/apps/web/src/lib/modules/i18n/messages.ts
new file mode 100644
index 000000000..cae3be8aa
--- /dev/null
+++ b/apps/web/src/lib/modules/i18n/messages.ts
@@ -0,0 +1,45 @@
+import { m } from "#lib/paraglide/messages.js";
+
+const messages = m as unknown as Record<
+ string,
+ ((inputs?: Record) => string) | undefined
+>;
+
+/**
+ * Resolves a paraglide message by a dynamically built key (e.g. weapon names
+ * keyed by id). Static call sites should always call the typed `m.*` functions
+ * directly instead; this exists only for id-indexed message families.
+ */
+export function dynamicMessage(key: string): string {
+ return messages[key]?.() ?? key;
+}
+
+/** Localized name of a main weapon. */
+export function mainWeaponName(weaponSplId: number) {
+ return dynamicMessage(`weapons_MAIN_${weaponSplId}`);
+}
+
+/** Localized name of a sub weapon. */
+export function subWeaponName(subWeaponSplId: number) {
+ return dynamicMessage(`weapons_SUB_${subWeaponSplId}`);
+}
+
+/** Localized name of a special weapon. */
+export function specialWeaponName(specialWeaponSplId: number) {
+ return dynamicMessage(`weapons_SPECIAL_${specialWeaponSplId}`);
+}
+
+/** Localized long name of a mode (e.g. "Splat Zones"). */
+export function modeLongName(mode: string) {
+ return dynamicMessage(`game_misc_MODE_LONG_${mode}`);
+}
+
+/** Localized stage name. */
+export function stageName(stageId: number) {
+ return dynamicMessage(`game_misc_STAGE_${stageId}`);
+}
+
+/** Localized weapon category name (e.g. "Shooters"). */
+export function weaponCategoryName(category: string) {
+ return dynamicMessage(`common_weapon_category_${category}`);
+}
diff --git a/apps/web/src/lib/utils/i18n.ts b/apps/web/src/lib/utils/i18n.ts
new file mode 100644
index 000000000..7066513a3
--- /dev/null
+++ b/apps/web/src/lib/utils/i18n.ts
@@ -0,0 +1,84 @@
+import { logger } from "@sendou/utils/logger";
+import type { LanguageCode } from "#lib/modules/i18n/languages.ts";
+
+/**
+ * Returns the localized display name for a given ISO country code using the specified language. If the country code is unknown or the function fails for other reason, returns the country code itself as a fallback.
+ */
+export function countryCodeToTranslatedName({
+ countryCode,
+ language,
+}: {
+ countryCode: string;
+ language: string;
+}) {
+ if (countryCode === "GB-WLS") return "Wales";
+ if (countryCode === "GB-SCT") return "Scotland";
+ if (countryCode === "GB-NIR") return "Northern Ireland";
+ if (countryCode === "GB-ENG") return "England";
+
+ try {
+ return (
+ new Intl.DisplayNames([language], { type: "region" }).of(countryCode) ??
+ countryCode
+ );
+ } catch (e) {
+ logger.error(
+ `Error getting display name for country code "${countryCode}":`,
+ e,
+ );
+ return countryCode;
+ }
+}
+
+/**
+ * Ordinal placement suffixes per language, keyed by the CLDR ordinal plural
+ * category given by `Intl.PluralRules(language, { type: "ordinal" })`. A leading
+ * `^` marks a suffix that should render as superscript. Languages without a
+ * written ordinal suffix use explicit `null` – rendering no suffix is preferred
+ * over borrowing the English one.
+ */
+const ORDINAL_SUFFIXES: Record<
+ LanguageCode,
+ Partial> | null
+> = {
+ da: null,
+ he: null,
+ nl: null,
+ en: { one: "^st", two: "^nd", few: "^rd", other: "^th" },
+ "es-ES": { other: "º" },
+ "es-US": { other: "^o" },
+ "fr-CA": { one: "^er", other: "^ème" },
+ "fr-EU": { one: "^er", other: "^ème" },
+ de: { other: "^." },
+ it: { many: "^o", other: "^o" },
+ ja: { other: "位" },
+ ko: { other: "^등" },
+ pl: { other: "^." },
+ "pt-BR": { other: "^º" },
+ ru: { other: "^ое" },
+ zh: { other: "名" },
+};
+
+const pluralRulesCache = new Map();
+
+/**
+ * Returns the localized ordinal suffix for a placement number in the given
+ * language (e.g. `"^st"` for `1` in English). A leading `^` marks a suffix the
+ * caller should render as superscript. Returns an empty string for languages
+ * without a written ordinal suffix.
+ */
+export function ordinalSuffix(placement: number, language: string): string {
+ const category = ordinalPluralRules(language).select(placement);
+
+ return ORDINAL_SUFFIXES[language as LanguageCode]?.[category] ?? "";
+}
+
+function ordinalPluralRules(language: string): Intl.PluralRules {
+ let rules = pluralRulesCache.get(language);
+ if (!rules) {
+ rules = new Intl.PluralRules(language, { type: "ordinal" });
+ pluralRulesCache.set(language, rules);
+ }
+
+ return rules;
+}
diff --git a/apps/web/src/lib/utils/strings.ts b/apps/web/src/lib/utils/strings.ts
new file mode 100644
index 000000000..e22bd688e
--- /dev/null
+++ b/apps/web/src/lib/utils/strings.ts
@@ -0,0 +1,26 @@
+/** Truncates to at most `max` characters, preferring to cut at a sentence boundary. */
+export function truncateBySentence(value: string, max: number) {
+ if (value.length <= max) {
+ return value;
+ }
+
+ // a sentence only ends at a terminator followed by whitespace, so that
+ // e.g. "18.00" does not split in the middle
+ const sentences = value.match(/[\s\S]+?(?:[.!?](?=\s|$)|\n|$)/g) || [];
+ let result = "";
+
+ for (const sentence of sentences) {
+ if ((result + sentence).length > max) {
+ break;
+ }
+ result += sentence;
+ }
+
+ // when cutting at a sentence boundary would leave most of the budget
+ // unused, a mid-sentence cut that fills it is more informative
+ if (result.length < max / 2) {
+ return value.slice(0, max).trim();
+ }
+
+ return result.trim();
+}
diff --git a/apps/web/src/lib/utils/urls.ts b/apps/web/src/lib/utils/urls.ts
new file mode 100644
index 000000000..acc4c1a04
--- /dev/null
+++ b/apps/web/src/lib/utils/urls.ts
@@ -0,0 +1,84 @@
+import { Config } from "#lib/config.ts";
+
+const STATIC_ASSETS_URL = Config.staticAssetsUrl;
+
+export const LEADERBOARDS_PAGE = "/leaderboards";
+export const SENDOUQ_PAGE = "/q";
+
+export const BLANK_IMAGE_URL = `${STATIC_ASSETS_URL}/img/blank.gif`;
+export const TIER_PLUS_URL = `${STATIC_ASSETS_URL}/img/tiers/plus`;
+
+export const FIRST_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/first.svg`;
+export const SECOND_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/second.svg`;
+export const THIRD_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/third.svg`;
+
+export interface UserLinkArgs {
+ discordId: string;
+ customUrl?: string | null;
+}
+
+export const userPage = (user: UserLinkArgs) =>
+ `/u/${user.customUrl ?? user.discordId}`;
+
+export const userSeasonsPage = ({
+ user,
+ season,
+}: {
+ user: UserLinkArgs;
+ season?: number;
+}) =>
+ `${userPage(user)}/seasons${typeof season === "number" ? `?season=${season}` : ""}`;
+
+export const teamPage = (customUrl: string) => `/t/${customUrl}`;
+
+export const topSearchPage = () => "/xsearch";
+export const topSearchPlayerPage = (playerId: number) =>
+ `${topSearchPage()}/player/${playerId}`;
+
+export const navIconUrl = (navItem: string) =>
+ `${STATIC_ASSETS_URL}/img/layout/${navItem}`;
+
+export const weaponCategoryUrl = (category: string) =>
+ `${STATIC_ASSETS_URL}/img/weapon-categories/${category}`;
+
+export const mainWeaponImageUrl = (mainWeaponSplId: number) =>
+ `${STATIC_ASSETS_URL}/img/main-weapons/${mainWeaponSplId}`;
+export const outlinedMainWeaponImageUrl = (mainWeaponSplId: number) =>
+ `${STATIC_ASSETS_URL}/img/main-weapons-outlined/${mainWeaponSplId}`;
+export const outlinedFiveStarMainWeaponImageUrl = (mainWeaponSplId: number) =>
+ `${STATIC_ASSETS_URL}/img/main-weapons-outlined-2/${mainWeaponSplId}`;
+export const outlinedTenStarMainWeaponImageUrl = (mainWeaponSplId: number) =>
+ `${STATIC_ASSETS_URL}/img/main-weapons-outlined-3/${mainWeaponSplId}`;
+export const subWeaponImageUrl = (subWeaponSplId: number) =>
+ `${STATIC_ASSETS_URL}/img/sub-weapons/${subWeaponSplId}`;
+export const specialWeaponImageUrl = (specialWeaponSplId: number) =>
+ `${STATIC_ASSETS_URL}/img/special-weapons/${specialWeaponSplId}`;
+export const stageImageUrl = (stageId: number) =>
+ `${STATIC_ASSETS_URL}/img/stages/${stageId}`;
+export const modeImageUrl = (mode: string) =>
+ `${STATIC_ASSETS_URL}/img/modes/${mode}`;
+export const tierImageUrl = (tier: string) =>
+ `${STATIC_ASSETS_URL}/img/tiers/${tier === "CALCULATING" ? "unranked" : tier.toLowerCase()}`;
+export const winnersImageUrl = ({
+ season,
+ placement,
+}: {
+ season: number;
+ placement: number;
+}) => `${STATIC_ASSETS_URL}/img/winners/${season}/${placement}`;
+
+export const resolveAvatarUrl = ({
+ customAvatarUrl,
+ discordId,
+ discordAvatar,
+ size,
+}: {
+ customAvatarUrl?: string | null;
+ discordId: string;
+ discordAvatar: string | null;
+ size: "sm" | "lg";
+}) =>
+ customAvatarUrl ??
+ (discordAvatar
+ ? `https://cdn.discordapp.com/avatars/${discordId}/${discordAvatar}.webp?size=${size === "lg" ? 240 : 80}`
+ : undefined);
diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts
new file mode 100644
index 000000000..0a65dd4fe
--- /dev/null
+++ b/apps/web/src/routes/+layout.server.ts
@@ -0,0 +1,25 @@
+import type { LayoutServerLoad } from "./$types";
+
+export const load: LayoutServerLoad = ({ locals }) => {
+ const user = locals.user;
+
+ return {
+ user: user
+ ? {
+ id: user.id,
+ username: user.username,
+ discordId: user.discordId,
+ discordAvatar: user.discordAvatar,
+ customUrl: user.customUrl,
+ customAvatarUrl: user.customAvatarUrl,
+ inGameName: user.inGameName,
+ friendCode: user.friendCode,
+ preferences: user.preferences,
+ languages: user.languages,
+ plusTier: user.plusTier,
+ roles: user.roles,
+ createdAt: user.createdAt,
+ }
+ : undefined,
+ };
+};
diff --git a/apps/web/src/routes/leaderboards/+page.svelte b/apps/web/src/routes/leaderboards/+page.svelte
index 7112d2f99..7159a8ae6 100644
--- a/apps/web/src/routes/leaderboards/+page.svelte
+++ b/apps/web/src/routes/leaderboards/+page.svelte
@@ -1 +1,12 @@
-Leaderboards
+
+
+
+
+
diff --git a/packages/components/README.md b/packages/components/README.md
new file mode 100644
index 000000000..e1c0048fb
--- /dev/null
+++ b/packages/components/README.md
@@ -0,0 +1,13 @@
+# @sendou/components
+
+The Svelte UI kit for sendou.ink (`svelte-big-bang.md` phase 2+). Design goal:
+progressive enhancement over ARIA maximalism — native primitives do the work
+where the platform provides them, handwritten ARIA patterns where it doesn't.
+
+Rules:
+
+- components know nothing about sendou.ink: no db types, no i18n — user-visible
+ strings arrive via props/snippets
+- styling uses the app's CSS variables (`vars.css`); components ship scoped
+ `
diff --git a/packages/components/src/ChipRadio.svelte b/packages/components/src/ChipRadio.svelte
new file mode 100644
index 000000000..5f14695b9
--- /dev/null
+++ b/packages/components/src/ChipRadio.svelte
@@ -0,0 +1,67 @@
+
+
+ onChange(value)}
+ class="radio"
+/>
+
+ {@render children()}
+
+
+
diff --git a/packages/components/src/ChipRadioGroup.svelte b/packages/components/src/ChipRadioGroup.svelte
new file mode 100644
index 000000000..e804eef7e
--- /dev/null
+++ b/packages/components/src/ChipRadioGroup.svelte
@@ -0,0 +1,44 @@
+
+
+
+ {@render children()}
+
+
+
diff --git a/packages/components/src/Menu.svelte b/packages/components/src/Menu.svelte
new file mode 100644
index 000000000..ae7d78f76
--- /dev/null
+++ b/packages/components/src/Menu.svelte
@@ -0,0 +1,181 @@
+
+
+
+ {@render trigger(triggerProps)}
+
+
+
+
+ {@render children()}
+
+
+{#if open}
+ setOpen(false)} aria-hidden="true">
+{/if}
+
+
diff --git a/packages/components/src/MenuItem.svelte b/packages/components/src/MenuItem.svelte
new file mode 100644
index 000000000..752dc1461
--- /dev/null
+++ b/packages/components/src/MenuItem.svelte
@@ -0,0 +1,112 @@
+
+
+{#snippet content()}
+ {#if icon}
+ {@render icon()}
+ {/if}
+ {@render children()}
+{/snippet}
+
+{#if href}
+ menu.close()}
+ >
+ {@render content()}
+
+{:else}
+
+ {@render content()}
+
+{/if}
+
+
diff --git a/packages/components/src/Popover.svelte b/packages/components/src/Popover.svelte
new file mode 100644
index 000000000..97a6e6825
--- /dev/null
+++ b/packages/components/src/Popover.svelte
@@ -0,0 +1,132 @@
+
+
+
+ {@render trigger(triggerProps)}
+
+
+ {@render children()}
+
+{#if open}
+ setOpen(false)} aria-hidden="true">
+{/if}
+
+
diff --git a/packages/components/src/Select.svelte b/packages/components/src/Select.svelte
new file mode 100644
index 000000000..1f9dfafe0
--- /dev/null
+++ b/packages/components/src/Select.svelte
@@ -0,0 +1,579 @@
+
+
+ open && positionPopover()}
+ onscroll={() => open && positionPopover()}
+/>
+
+
+ {#if label}
+
{label}
+ {/if}
+
setOpen(!open)}
+ onkeydown={onTriggerKeydown}
+ >
+
+ {#if currentKey !== null && valueContent}
+ {@render valueContent(currentKey)}
+ {:else if selectedText !== undefined}
+ {selectedText}
+ {:else}
+ {placeholder}
+ {/if}
+
+
+
+
+
+
+
+
+ {#if clearable && currentKey !== null}
+
commitSelection(null)}
+ >
+
+
+
+
+ {clearText}
+
+ {/if}
+
+
+ {#if search}
+
+
+
+
+
+
+
{
+ searchValue = "";
+ searchInputElement?.focus();
+ }}
+ >
+
+
+
+
+
+
+ {/if}
+
+
+
+
+{#if open}
+ setOpen(false)}
+ aria-hidden="true"
+ >
+{/if}
+
+
diff --git a/packages/components/src/SelectItem.svelte b/packages/components/src/SelectItem.svelte
new file mode 100644
index 000000000..4812fb098
--- /dev/null
+++ b/packages/components/src/SelectItem.svelte
@@ -0,0 +1,72 @@
+
+
+
+ {
+ if (!isDisabled) select.select(id);
+ }}
+ onpointermove={() => {
+ if (!isDisabled) select.setFocusedKey(id);
+ }}
+ {@attach register}
+>
+ {@render children()}
+
+
+
diff --git a/packages/components/src/SelectItemSection.svelte b/packages/components/src/SelectItemSection.svelte
new file mode 100644
index 000000000..078b322b7
--- /dev/null
+++ b/packages/components/src/SelectItemSection.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+ {#if headingImg}{@render headingImg()}{/if}
+ {heading}
+
+
+ {@render children()}
+
+
+
diff --git a/packages/components/src/Tab.svelte b/packages/components/src/Tab.svelte
new file mode 100644
index 000000000..24ab850bf
--- /dev/null
+++ b/packages/components/src/Tab.svelte
@@ -0,0 +1,83 @@
+
+
+ tabs.select(id)}
+ {onkeydown}
+ {@attach registerTab}
+>
+
+
diff --git a/packages/components/src/TabList.svelte b/packages/components/src/TabList.svelte
new file mode 100644
index 000000000..94c3a2a1a
--- /dev/null
+++ b/packages/components/src/TabList.svelte
@@ -0,0 +1,32 @@
+
+
+
diff --git a/packages/components/src/TabPanel.svelte b/packages/components/src/TabPanel.svelte
new file mode 100644
index 000000000..81ad1aed1
--- /dev/null
+++ b/packages/components/src/TabPanel.svelte
@@ -0,0 +1,27 @@
+
+
+{#if selected}
+
+ {@render children()}
+
+{/if}
diff --git a/packages/components/src/Tabs.svelte b/packages/components/src/Tabs.svelte
new file mode 100644
index 000000000..b2b0933cb
--- /dev/null
+++ b/packages/components/src/Tabs.svelte
@@ -0,0 +1,285 @@
+
+
+
+ {@render children()}
+
+
+
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
new file mode 100644
index 000000000..81e504fc5
--- /dev/null
+++ b/packages/components/src/index.ts
@@ -0,0 +1,14 @@
+export { default as Button } from "./Button.svelte";
+export { default as ChipRadio } from "./ChipRadio.svelte";
+export { default as ChipRadioGroup } from "./ChipRadioGroup.svelte";
+export { default as Menu } from "./Menu.svelte";
+export { default as MenuItem } from "./MenuItem.svelte";
+export type { MenuTriggerProps } from "./menu-context.ts";
+export { default as Popover } from "./Popover.svelte";
+export { default as Select } from "./Select.svelte";
+export { default as SelectItem } from "./SelectItem.svelte";
+export { default as SelectItemSection } from "./SelectItemSection.svelte";
+export { default as Tab } from "./Tab.svelte";
+export { default as TabList } from "./TabList.svelte";
+export { default as TabPanel } from "./TabPanel.svelte";
+export { default as Tabs } from "./Tabs.svelte";
diff --git a/packages/components/src/menu-context.ts b/packages/components/src/menu-context.ts
new file mode 100644
index 000000000..64a54f7cd
--- /dev/null
+++ b/packages/components/src/menu-context.ts
@@ -0,0 +1,25 @@
+import { getContext, setContext } from "svelte";
+
+export interface MenuTriggerProps {
+ readonly "aria-expanded": boolean;
+ readonly "aria-haspopup": "menu";
+ onclick: () => void;
+}
+
+export interface MenuContext {
+ close: () => void;
+}
+
+const KEY = Symbol("menu");
+
+export function setMenuContext(context: MenuContext) {
+ setContext(KEY, context);
+}
+
+export function getMenuContext(): MenuContext {
+ const context = getContext(KEY);
+ if (!context) {
+ throw new Error("Menu items must be used inside ");
+ }
+ return context;
+}
diff --git a/packages/components/src/select-context.ts b/packages/components/src/select-context.ts
new file mode 100644
index 000000000..1224e4bd7
--- /dev/null
+++ b/packages/components/src/select-context.ts
@@ -0,0 +1,27 @@
+import { getContext, setContext } from "svelte";
+
+export interface SelectContext {
+ readonly selectedKey: string | number | null;
+ readonly focusedKey: string | number | null;
+ registerItem: (
+ key: string | number,
+ element: HTMLElement,
+ options: { textValue: string; disabled: boolean },
+ ) => () => void;
+ select: (key: string | number) => void;
+ setFocusedKey: (key: string | number | null) => void;
+}
+
+const KEY = Symbol("select");
+
+export function setSelectContext(context: SelectContext) {
+ setContext(KEY, context);
+}
+
+export function getSelectContext(): SelectContext {
+ const context = getContext(KEY);
+ if (!context) {
+ throw new Error("Select items must be used inside ");
+ }
+ return context;
+}
diff --git a/packages/components/src/tabs-context.ts b/packages/components/src/tabs-context.ts
new file mode 100644
index 000000000..51ee9b0a4
--- /dev/null
+++ b/packages/components/src/tabs-context.ts
@@ -0,0 +1,23 @@
+import { getContext, setContext } from "svelte";
+
+export interface TabsContext {
+ readonly selectedKey: string | null;
+ readonly orientation: "horizontal" | "vertical";
+ select: (key: string) => void;
+ registerTab: (key: string, element: HTMLElement) => () => void;
+ moveFocus: (fromKey: string, direction: "next" | "previous" | "first" | "last") => void;
+}
+
+const KEY = Symbol("tabs");
+
+export function setTabsContext(context: TabsContext) {
+ setContext(KEY, context);
+}
+
+export function getTabsContext(): TabsContext {
+ const context = getContext(KEY);
+ if (!context) {
+ throw new Error("Tabs components must be used inside ");
+ }
+ return context;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1daac89b5..7d89884c4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -22,6 +22,12 @@ importers:
'@epic-web/cachified':
specifier: 5.6.3
version: 5.6.3
+ '@lucide/svelte':
+ specifier: 0.544.0
+ version: 0.544.0(svelte@5.56.9)
+ '@sendou/components':
+ specifier: workspace:*
+ version: link:../../packages/components
'@sendou/in-game-lists':
specifier: workspace:*
version: link:../../packages/in-game-lists
@@ -368,6 +374,15 @@ importers:
specifier: 4.1.10
version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(@vitest/ui@4.1.10)(vite@8.2.1(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))
+ packages/components:
+ devDependencies:
+ svelte:
+ specifier: ^5.56.9
+ version: 5.56.9
+ typescript:
+ specifier: ~6.0.3
+ version: 6.0.3
+
packages/in-game-lists:
devDependencies:
typescript:
@@ -1208,6 +1223,11 @@ packages:
'@lix-js/sdk@0.12.2':
resolution: {integrity: sha512-sKGOPdVKdChS5q60F4VQE4ZTcf0B7ZJM3KGCVQFN2suVNKhHcllbsCiwRYuM5WL7Yu1ql7YexltJ2FCsDqKePg==}
+ '@lucide/svelte@0.544.0':
+ resolution: {integrity: sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw==}
+ peerDependencies:
+ svelte: ^5
+
'@mjackson/headers@0.10.0':
resolution: {integrity: sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ==}
@@ -5904,6 +5924,10 @@ snapshots:
'@lix-js/sdk-linux-x64': 0.12.2
'@lix-js/sdk-win32-x64': 0.12.2
+ '@lucide/svelte@0.544.0(svelte@5.56.9)':
+ dependencies:
+ svelte: 5.56.9
+
'@mjackson/headers@0.10.0': {}
'@napi-rs/canvas-android-arm64@1.0.3':