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 @@ + + +{label} 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} + {placementString} +{/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 @@ + + +
+ {title} + {#if tier.isPlus} + {title} + {/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 @@ + + +{label} 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 @@ + + + + + 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} +
+ + {entry.firstOfTier.name}{entry.firstOfTier.isPlus ? "+" : ""} +
+ {/if} +
+ +
+
{entry.placementRank}
+
+ +
+ {#if typeof entry.weaponSplId === "number"} + + {/if} +
{entry.username}
+
{entry.power}
+
+
+
+ {#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 @@ + + +
+ {#each shownEntries as entry (entry.entryId)} + {#if entry.firstOfTier && showTiers} +
+ + {entry.firstOfTier.name}{entry.firstOfTier.isPlus ? "+" : ""} +
+ {/if} + +
+
{entry.placementRank}
+
+ +
+ {#if typeof entry.weaponSplId === "number"} + + {/if} +
{entry.username}
+ {#if entry.pendingPlusTier} +
+ ➜ +{entry.pendingPlusTier} +
+ {/if} +
{entry.power.toFixed(2)}
+
+
+ {/each} +
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} + {#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 @@ + + +
+ {#each entries as entry (entry.entryId)} + +
+
{entry.placementRank}
+ {#if entry.discordId} + + {/if} + +
{entry.name}
+
{entry.power.toFixed(1)}
+
+
+ {/each} +
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 @@ + + + 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" +/> + + + 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)} + + +
+ +
+{#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} + +{/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)} + + +{#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} + + {#if clearable && currentKey !== null} + + {/if} + +
+ {#if search} +
+ + + +
+ {/if} +
+ {@render children()} + {#if !hasVisibleItems} +
{noResultsText}
+ {/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 @@ + + + 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 @@ + + +
+
+ {@render children()} +
+
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