Scrims routes, chat rebuild UI and match-page components for apps/web

This commit is contained in:
Kalle
2026-08-17 08:58:40 +03:00
parent b65cd68c52
commit d42e6f9bad
88 changed files with 9515 additions and 16 deletions

View File

@@ -372,6 +372,8 @@
"chat.newMessages": "New messages",
"chat.sidebar.title": "Chat",
"chat.sidebar.noActiveChats": "No active chats",
"chat.sidebar.inactive": "Inactive",
"chat.room.scrim": "Scrim",
"fc.title": "Friend code",
"fc.whereToFind": "Find your friend code on your Nintendo Switch (2)",
"fc.onceSetStaffOnly": "Once set, only a member of staff can change it",

View File

@@ -34,7 +34,8 @@
"nprogress": "^0.2.0",
"remeda": "2.39.0",
"sql-formatter": "^15.8.2",
"valibot": "^1.4.2"
"valibot": "^1.4.2",
"web-haptics": "0.0.6"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.24.1",

View File

@@ -0,0 +1,83 @@
<!--
@component
Confirmation flow for destructive or otherwise notable actions: renders a
caller-provided trigger that opens a dialog with a heading, an optional
description, and a confirm button running `onConfirm` (the dialog closes after
it resolves). Svelte counterpart of the React `FormWithConfirm`, adapted to
remote functions: instead of submitting hidden form fields, confirming runs the
given callback.
-->
<script lang="ts">
import { Button, Dialog } from "@sendou/components";
import type { ComponentProps } from "svelte";
import type { Snippet } from "svelte";
import FormMessage from "#lib/components/FormMessage.svelte";
import { m } from "#lib/paraglide/messages.js";
interface Props {
/** Opens the dialog via the received `onclick`. Callers typically spread it on a button along with their own `data-testid`. */
trigger: Snippet<[{ onclick: () => void; type: "button" }]>;
dialogHeading: string;
/** Optional explanatory text shown below the heading in the confirm dialog. */
description?: string;
/** Confirming runs this callback; the dialog closes once it resolves. */
onConfirm: () => Promise<unknown> | void;
submitButtonText?: string;
submitButtonVariant?: ComponentProps<typeof Button>["variant"];
}
let {
trigger,
dialogHeading,
description,
onConfirm,
submitButtonText,
submitButtonVariant = "destructive",
}: Props = $props();
let open = $state(false);
let confirming = $state(false);
async function confirm() {
confirming = true;
try {
await onConfirm();
} finally {
confirming = false;
open = false;
}
}
</script>
{@render trigger({
onclick: () => {
open = true;
},
type: "button",
})}
{#if open}
<Dialog
isDismissable
onClose={() => {
open = false;
}}
>
<div class="stack md">
<h2 class="text-md text-center">{dialogHeading}</h2>
{#if description}
<FormMessage type="info">{description}</FormMessage>
{/if}
<div class="stack horizontal md justify-center mt-2">
<Button
variant={submitButtonVariant}
testId="confirm-button"
disabled={confirming}
onclick={confirm}
>
{submitButtonText ?? m.common_actions_delete()}
</Button>
</div>
</div>
</Dialog>
{/if}

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children?: Snippet;
class?: string;
smallText?: boolean;
}
let { children, class: className, smallText }: Props = $props();
</script>
<div class={["divider", className, { smallText }]}>{@render children?.()}</div>
<style>
.divider {
display: flex;
width: 100%;
align-items: center;
color: var(--color-text-accent);
font-size: var(--font-lg);
text-align: center;
&::before,
&::after {
flex: 1;
min-width: 1rem;
border-bottom: 2px solid var(--color-bg-high);
content: "";
}
&:not(:empty)::before {
margin-right: 0.25em;
}
&:not(:empty)::after {
margin-left: 0.25em;
}
}
.smallText {
font-size: var(--font-sm);
}
</style>

View File

@@ -0,0 +1,40 @@
<!--
@component
Renders a `<time>` element with the given date formatted according to the user's
locale preferences.
During SSR the formatted text is hidden (via `invisible`) while still reserving
one line of height to avoid layout shift on hydration. The `datetime` attribute
is always set to the ISO string for machine readability and a11y.
-->
<script lang="ts">
import { dateTimeFormat } from "#lib/modules/intl/date-time-format.ts";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
interface Props {
/** The date to render. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */
date: Date | number;
/** Formatting options forwarded to `Intl.DateTimeFormat`. Combined with the user's locale and hour cycle preferences. */
options: Intl.DateTimeFormatOptions;
/** Optional extra class names appended to the rendered `<time>` element. */
class?: string;
/** When `true`, renders inline; otherwise the element is displayed as a block. Defaults to block. */
inline?: boolean;
/** Optional test id forwarded to the rendered `<time>` element. */
testId?: string;
}
let { date, options, class: className, inline, testId }: Props = $props();
const formatting = $derived(dateTimeFormat(options));
const dateObject = $derived(
typeof date === "number" ? databaseTimestampToDate(date) : date,
);
</script>
<time
data-testid={testId}
datetime={dateObject.toISOString()}
class={[{ block: !inline, invisible: !formatting.isLoaded }, className]}
>{formatting.formatter.format(dateObject)}</time
>

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { Check, Minus, X } from "@lucide/svelte";
import type { Snippet } from "svelte";
import type { Tables } from "#lib/server/db/tables.ts";
type Sentiment = Tables["PrivateUserNote"]["sentiment"];
const BADGE_CLASS: Record<Sentiment, string> = {
POSITIVE: "positive",
NEUTRAL: "neutral",
NEGATIVE: "negative",
};
const SIZE_CLASS = {
xs: "badgeXs",
sm: "badgeSm",
md: "badgeMd",
} as const;
interface Props {
sentiment?: Sentiment | null;
size?: keyof typeof SIZE_CLASS;
class?: string;
onclick?: () => void;
children: Snippet;
}
let {
sentiment,
size = "md",
class: className,
onclick,
children,
}: Props = $props();
</script>
<!--
@component
Wraps an avatar (or any node) and overlays a sentiment badge on the bottom-left corner when
`sentiment` is set: POSITIVE → green check, NEGATIVE → red cross, NEUTRAL → grey dash. Renders the
children without a badge when `sentiment` is `null`/`undefined`. `size` scales the badge to match
the wrapped avatar (`xs` for tiny avatars, `sm` for small avatars, `md` for large ones).
`onclick` makes the whole wrapper (avatar and badge) clickable. It is kept out of the tab order, so
only use it as a shortcut to an action that is also available elsewhere.
-->
<!-- the element is a real <button> whenever it is clickable -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<svelte:element
this={onclick ? "button" : "div"}
type={onclick ? "button" : undefined}
class={["wrapper", className, { clickable: Boolean(onclick) }]}
{onclick}
tabindex={onclick ? -1 : undefined}
>
{@render children()}
{#if sentiment}
<span
class={["badge", SIZE_CLASS[size], BADGE_CLASS[sentiment]]}
aria-hidden="true"
>
{#if sentiment === "POSITIVE"}
<Check />
{:else if sentiment === "NEGATIVE"}
<X />
{:else}
<Minus />
{/if}
</span>
{/if}
</svelte:element>
<style>
.wrapper {
position: relative;
display: inline-flex;
flex-shrink: 0;
width: fit-content;
}
.clickable {
cursor: pointer;
border: none;
padding: 0;
background: none;
}
.badge {
position: absolute;
bottom: 15%;
inset-inline-start: 15%;
transform: translate(-50%, 50%);
display: grid;
place-items: center;
border-radius: var(--radius-full);
border: 2px solid var(--color-bg);
color: var(--color-bg);
& > :global(svg) {
stroke-width: 3;
}
}
.badgeMd {
width: 1.25rem;
height: 1.25rem;
& > :global(svg) {
width: 0.75rem;
height: 0.75rem;
}
}
.badgeSm {
width: 0.9rem;
height: 0.9rem;
border-width: 1.5px;
& > :global(svg) {
width: 0.6rem;
height: 0.6rem;
}
}
.badgeXs {
width: 0.65rem;
height: 0.65rem;
border-width: 1.5px;
& > :global(svg) {
width: 0.45rem;
height: 0.45rem;
}
}
.positive {
background-color: var(--color-success);
}
.negative {
background-color: var(--color-error);
}
.neutral {
background-color: var(--color-text-high);
}
</style>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { StageId } from "@sendou/in-game-lists/types";
import { stageName } from "#lib/modules/i18n/messages.ts";
import { stageImageUrl } from "#lib/utils/urls.ts";
import Image from "./Image.svelte";
interface Props {
stageId: StageId;
class?: string;
containerClass?: string;
width?: number;
height?: number;
testId?: string;
loading?: "lazy" | "eager";
}
let { stageId, height, ...rest }: Props = $props();
const label = $derived(stageName(stageId));
const resolvedHeight = $derived(
height ?? (rest.width ? rest.width * 0.5625 : undefined),
);
</script>
<Image
{...rest}
alt={label}
title={label}
path={stageImageUrl(stageId)}
height={resolvedHeight}
/>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
noRowHover?: boolean;
}
let { children, noRowHover }: Props = $props();
</script>
<div class="container">
<table class={["table", { noRowHover }]}>
{@render children()}
</table>
</div>
<style>
.container {
position: relative;
width: 100%;
overflow: auto;
}
.table {
width: max-content;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--font-xs);
text-align: left;
border-color: var(--color-border);
& > :global(thead) {
font-size: var(--font-2xs);
}
&:not(.noRowHover) :global(tbody tr:hover) {
background-color: var(--color-bg-high);
}
& > :global(thead > tr > th) {
padding: var(--s-2);
}
& > :global(tbody > tr > td) {
padding: var(--s-2) var(--s-3);
}
& :global(tr:first-child td) {
border-top: 1px solid var(--color-border);
}
& :global(td) {
border-bottom: 1px solid var(--color-border);
}
}
</style>

View File

@@ -0,0 +1,104 @@
<!--
@component
A time rendered as a button that opens a popover with the full date/time
(including time zone) and a shortcut for copying a Discord timestamp code.
-->
<script lang="ts">
import { Check, Clipboard } from "@lucide/svelte";
import { Button, Popover } from "@sendou/components";
import LocaleTime from "#lib/components/LocaleTime.svelte";
import { m } from "#lib/paraglide/messages.js";
const COPY_SUCCESS_DURATION_MS = 2000;
interface Props {
date: Date;
options?: Intl.DateTimeFormatOptions;
underline?: boolean;
class?: string;
footerText?: string;
}
let {
date,
options = {
minute: "numeric",
hour: "numeric",
day: "numeric",
month: "numeric",
},
underline = true,
class: className,
footerText,
}: Props = $props();
let copySuccess = $state(false);
let copyResetTimeout: ReturnType<typeof setTimeout> | undefined;
function copyTimestampForDiscord() {
navigator.clipboard.writeText(`<t:${date.valueOf() / 1000}:F>`).then(
() => {
copySuccess = true;
clearTimeout(copyResetTimeout);
copyResetTimeout = setTimeout(() => {
copySuccess = false;
}, COPY_SUCCESS_DURATION_MS);
},
() => {},
);
}
</script>
<div>
<Popover>
{#snippet trigger(triggerProps)}
<button
type="button"
class={[className, "clickable", "textOnlyButton", { dotted: underline }]}
{...triggerProps}
>
<LocaleTime {date} {options} inline />
</button>
{/snippet}
<div class="stack sm">
<div class="text-center">
<LocaleTime
{date}
options={{
timeZoneName: "long",
hour: "numeric",
minute: "numeric",
}}
/>
</div>
<Button size="miniscule" variant="minimal" onclick={copyTimestampForDiscord}>
{#snippet icon()}
{#if copySuccess}<Check />{:else}<Clipboard />{/if}
{/snippet}
{m.common_actions_copyTimestampForDiscord()}
</Button>
{#if footerText}
<div class="text-lighter text-center mt-2 text-xs">
{footerText}
</div>
{/if}
</div>
</Popover>
</div>
<style>
.textOnlyButton {
cursor: pointer;
border: 0;
background-color: inherit;
color: inherit;
margin: 0;
padding: 0;
}
.dotted {
text-decoration-style: dotted;
text-decoration-line: underline;
text-decoration-thickness: 2px;
}
</style>

View File

@@ -0,0 +1,297 @@
<!--
@component
Generic filter pill bar: active filters render as pills opening a popover with
the filter's inputs, hidden filters are added via the "Add filter" menu, and an
optional reset button plus extra actions render at the end.
-->
<script lang="ts">
import { ChevronDown, Plus, RotateCcw, X } from "@lucide/svelte";
import { Button, Menu, MenuItem, Popover } from "@sendou/components";
import type { Snippet } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import { m } from "#lib/paraglide/messages.js";
import type { FilterBarPill } from "./filter-bar-types.ts";
interface Props {
pills: FilterBarPill[];
/** Resets every pill's param(s) to defaults. Renders the reset button. */
onReset?: () => void;
actions?: Snippet;
}
let { pills, onReset, actions }: Props = $props();
const justAddedKeys = new SvelteSet<string>();
let openPillKey = $state<string | null>(null);
const visiblePills = $derived(pills.filter(isVisible));
const hiddenPills = $derived(pills.filter((pill) => !isVisible(pill)));
function isVisible(pill: FilterBarPill) {
return pill.formattedValue !== null || justAddedKeys.has(pill.key);
}
function addPill(pill: FilterBarPill) {
justAddedKeys.add(pill.key);
openPillKey = pill.key;
pill.onAdd?.();
}
function removePill(pill: FilterBarPill) {
justAddedKeys.delete(pill.key);
if (openPillKey === pill.key) {
openPillKey = null;
}
pill.onRemove?.();
}
function resetPills() {
justAddedKeys.clear();
openPillKey = null;
onReset?.();
}
</script>
<div class="bar">
{#each visiblePills as pill (pill.key)}
{@render filterPill(pill)}
{/each}
{#if hiddenPills.length > 0}
{@render addFilterMenu()}
{/if}
{#if onReset || actions}
<div class="actions">
{#if onReset}
<Button onclick={resetPills}>
{#snippet icon()}
<RotateCcw />
{/snippet}
{m.common_actions_reset()}
</Button>
{/if}
{#if actions}{@render actions()}{/if}
</div>
{/if}
</div>
{#snippet filterPill(pill: FilterBarPill)}
<div class="pill">
<Popover
isOpen={openPillKey === pill.key}
onOpenChange={(isOpen) => {
openPillKey = isOpen ? pill.key : null;
}}
popoverClass={["filterPopover", pill.popoverClass]
.filter(Boolean)
.join(" ")}
>
{#snippet trigger(triggerProps)}
<button
type="button"
class="trigger"
data-active={pill.formattedValue !== null}
data-testid={pill.testId}
{...triggerProps}
>
{#if pill.icon}
<span class="icon">{@render pill.icon()}</span>
{/if}
<span>{pill.name}</span>
{#if pill.formattedValue !== null}
<span class="value">{pill.formattedValue}</span>
{/if}
<ChevronDown class="chevron" />
</button>
{/snippet}
{@render pill.popover()}
</Popover>
{#if pill.onRemove}
<button
type="button"
class="removeButton"
aria-label={`Remove ${pill.name} filter`}
data-testid={pill.testId ? `${pill.testId}-remove` : undefined}
onclick={() => removePill(pill)}
>
<X />
</button>
{/if}
</div>
{/snippet}
{#snippet addFilterMenu()}
<Menu>
{#snippet trigger(triggerProps)}
<div class="pill">
<button
type="button"
class="trigger"
data-testid="add-filter-button"
{...triggerProps}
>
<Plus class="plus" />
<span>{m.common_filterBar_addFilter()}</span>
</button>
</div>
{/snippet}
{#each hiddenPills as pill (pill.key)}
<MenuItem
icon={pill.icon}
onAction={() => addPill(pill)}
testId={pill.testId ? `menu-item-${pill.testId}` : undefined}
>
{pill.name}
</MenuItem>
{/each}
</Menu>
{/snippet}
<style>
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1-5);
}
.pill {
display: inline-flex;
align-items: center;
height: var(--selector-size);
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
transition: background-color 0.15s;
&:has(.trigger:hover) {
background-color: var(--color-bg-high);
}
& :global(.filterPopover) {
min-width: 14rem;
}
}
.trigger {
display: inline-flex;
align-items: center;
gap: var(--s-1);
height: 100%;
padding: 0 var(--s-2);
border: none;
border-radius: inherit;
background-color: transparent;
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
cursor: pointer;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}
.pill:has(.removeButton) & {
padding-right: var(--s-1);
}
& :global(.chevron),
& :global(.plus) {
width: 14px;
height: 14px;
color: var(--color-text-high);
}
}
.removeButton {
display: inline-flex;
align-items: center;
height: 100%;
padding: 0 var(--s-1-5);
border: none;
border-radius: inherit;
background-color: transparent;
color: var(--color-text-high);
cursor: pointer;
& > :global(svg) {
width: 14px;
height: 14px;
}
&:hover {
color: var(--color-error);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}
}
.actions {
display: contents;
}
.actions :global {
button {
display: inline-flex;
align-items: center;
gap: var(--s-1);
height: var(--selector-size);
padding: 0 var(--s-2);
border: var(--border-style-high);
border-radius: var(--radius-selector);
background-color: transparent;
color: var(--color-text-high);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
white-space: nowrap;
cursor: pointer;
transition:
background-color 0.15s,
color 0.15s;
&:hover {
background-color: var(--color-bg-high);
color: var(--color-text);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 2px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
& .buttonIcon {
min-width: 14px;
max-width: 14px;
margin-inline-end: 0;
}
& svg {
width: 14px;
min-width: 14px;
max-width: 14px;
height: 14px;
margin-inline-end: 0;
}
}
}
.icon {
display: inline-flex;
& > :global(svg) {
width: 14px;
height: 14px;
}
}
.value {
color: var(--color-text-accent);
}
</style>

View File

@@ -0,0 +1,18 @@
import type { Snippet } from "svelte";
export interface FilterBarPill {
key: string;
/** Translated filter name shown on the pill and in the add filter menu. */
name: string;
/** Translated current value shown on the pill. Null when the filter is at its default. */
formattedValue: string | null;
/** Popover content. Inputs inside write search params directly (instant apply). */
popover: Snippet;
/** Resets the pill's param(s) to defaults. Renders the remove button. */
onRemove?: () => void;
/** Writes a starting value when the pill is added from the menu. */
onAdd?: () => void;
icon?: Snippet;
popoverClass?: string;
testId?: string;
}

View File

@@ -6,6 +6,9 @@ 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 ChatSidebar from "#lib/features/chat/components/ChatSidebar.svelte";
import { chatUi } from "#lib/features/chat/chat-state.svelte.ts";
import { getChatRooms } from "#lib/features/chat/chat.remote.ts";
import { getNotifications } from "#lib/features/notifications/notifications.remote.ts";
import { UnseenNotificationsDot } from "#lib/features/notifications/notifications-state.svelte.ts";
import { toNotificationRows } from "#lib/features/notifications/notifications-utils.ts";
@@ -79,9 +82,18 @@ const unseenFriendRequests = $derived(
const isFrontPage = $derived(page.url.pathname === "/");
// xxx: wire to the chat sidebar when the chat rebuild lands (svelte-big-bang
// phase 3); until then the button renders for layout parity with the React app
const noopUntilChatRebuild = () => {};
// chat data streams lazily after hydration, like the bell
const chatRoomsQuery = $derived(
mounted.current && user ? getChatRooms() : null,
);
const chatUnreadCount = $derived(
chatRoomsQuery?.current?.rooms.reduce(
(total, room) => total + room.unseenCount,
0,
) ?? 0,
);
let chatModalOpen = $state(false);
const breadcrumbs = $derived(page.data.breadcrumbs ?? []);
const currentPageText = $derived(breadcrumbs.at(-1)?.text);
@@ -155,9 +167,10 @@ function handleScroll() {
lastScrollY = currentScrollY;
}
// the tablet-only sidenav modal closes on navigation
// the tablet-only modals close on navigation
afterNavigate(() => {
sideNavModalOpen = false;
chatModalOpen = false;
});
function showModal(dialog: HTMLDialogElement) {
@@ -419,9 +432,15 @@ function formatRelativeDate(timestamp: number) {
showSupport={Boolean(!user?.roles.includes("MINOR_SUPPORT"))}
showSearch={Boolean(user)}
isLoggedIn={Boolean(user)}
onChatToggle={user ? noopUntilChatRebuild : undefined}
onChatModalToggle={user ? noopUntilChatRebuild : undefined}
chatUnreadCount={0}
onChatToggle={user && !chatUi.sidebarOpen
? () => chatUi.toggleSidebar()
: undefined}
onChatModalToggle={user
? () => {
chatModalOpen = !chatModalOpen;
}
: undefined}
{chatUnreadCount}
/>
<div id="nprogress-anchor" aria-hidden="true"></div>
</header>
@@ -429,6 +448,34 @@ function formatRelativeDate(timestamp: number) {
<Footer {patrons} gitCommit={GIT_COMMIT} />
</div>
{#if user && chatUi.sidebarOpen}
<div class="chatSidebar">
<ChatSidebar
onClose={() => {
chatUi.sidebarOpen = false;
}}
/>
</div>
{/if}
{#if chatModalOpen}
<dialog
class="chatSidebarModal"
closedby="any"
aria-label={m.common_chat_sidebar_title()}
onclose={() => {
chatModalOpen = false;
}}
{@attach showModal}
>
<ChatSidebar
onClose={() => {
chatModalOpen = false;
}}
/>
</dialog>
{/if}
{#if sideNavModalOpen}
<dialog
class="sideNavModal"
@@ -745,4 +792,42 @@ function formatRelativeDate(timestamp: number) {
display: none;
}
}
.chatSidebar {
display: none;
background-color: var(--color-bg-nav);
min-width: var(--layout-sidenav-width);
max-width: var(--layout-sidenav-width);
border-left: 1.5px solid var(--color-border);
position: sticky;
top: 0;
right: 0;
height: 100dvh;
flex-direction: column;
overflow: hidden;
@media screen and (min-width: 1000px) {
display: flex;
}
}
.chatSidebarModal {
position: fixed;
inset: var(--layout-nav-height) 0 0 auto;
margin: 0;
border: none;
padding: 0;
height: calc(100dvh - var(--layout-nav-height));
max-height: none;
width: var(--layout-sidenav-width);
max-width: none;
background-color: var(--color-bg-nav);
border-left: 1.5px solid var(--color-border);
&::backdrop {
top: var(--layout-nav-height);
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
}
}
</style>

View File

@@ -12,6 +12,7 @@ import {
import { MediaQuery } from "svelte/reactivity";
import Avatar from "#lib/components/Avatar.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import ChatSidebar from "#lib/features/chat/components/ChatSidebar.svelte";
import NotificationContent from "#lib/features/notifications/components/NotificationContent.svelte";
import { markNotificationsSeen } from "#lib/features/notifications/notifications.remote.ts";
import { m } from "#lib/paraglide/messages.js";
@@ -89,9 +90,6 @@ function closePanel() {
}
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;
@@ -172,6 +170,21 @@ function handleGhostTabPress(index: number) {
</MobilePanel>
{/if}
{#if activePanel === "chat" && user}
<MobilePanel
title={m.front_mobileNav_chat()}
onClose={closePanel}
ghostTabCount={ghostTabs.length}
onGhostTabPress={handleGhostTabPress}
{skipAnimation}
>
{#snippet icon()}<MessageSquare size={18} />{/snippet}
<div class="chatPanelBody">
<ChatSidebar />
</div>
</MobilePanel>
{/if}
{#if activePanel === "you" && user}
<MobilePanel
title={m.front_mobileNav_you()}
@@ -407,4 +420,11 @@ function handleGhostTabPress(index: number) {
background-color: var(--color-bg-higher);
}
}
.chatPanelBody {
height: min(65dvh, 520px);
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import type { Snippet } from "svelte";
import {
type BannerHost,
joinInfo,
screenNotice,
} from "./MatchBanner.svelte";
interface Props {
icon: Snippet;
header: string;
subtitle?: string;
screenLegal?: boolean;
joinPool?: string | null;
joinPass?: string | null;
host?: BannerHost | null;
topRight?: Snippet;
testId?: string;
}
let {
icon,
header,
subtitle,
screenLegal,
joinPool,
joinPass,
host,
topRight,
testId,
}: Props = $props();
</script>
<div class="iconBanner" data-testid={testId}>
{@render icon()}
<div class="iconBannerHeader">{header}</div>
{#if subtitle}
<div class="iconBannerSubtitle">{subtitle}</div>
{/if}
{#if joinPool}
{@render joinInfo({ pool: joinPool, pass: joinPass, host })}
{/if}
{#if screenLegal !== undefined}
{@render screenNotice(screenLegal)}
{/if}
{#if topRight}
<div class="iconBannerBottomRight">{@render topRight()}</div>
{/if}
</div>
<style>
.iconBanner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-0-5);
line-height: 1.2;
width: 100%;
height: var(--banner-height);
border-radius: var(--radius-box);
background-color: var(--color-bg-higher);
}
.iconBannerHeader {
font-size: var(--font-md);
font-weight: var(--weight-bold);
}
.iconBannerSubtitle {
font-size: var(--font-xs);
color: var(--color-text-high);
}
.iconBannerBottomRight {
position: absolute;
top: var(--s-2);
right: var(--s-2);
}
</style>

View File

@@ -0,0 +1,545 @@
<script module lang="ts">
import type { CommonUser } from "#lib/server/kysely.ts";
import type { MatchTimelineProps } from "./MatchTimeline.svelte";
export interface ActionTabTeam {
id: number;
name: string;
avatar?: string;
}
export interface SetEndingData extends MatchTimelineProps {
score: { alpha: number; bravo: number };
currentRosters: { alpha: CommonUser[]; bravo: CommonUser[] };
setEndingTeamIds: number[];
}
</script>
<script lang="ts">
import { Check } from "@lucide/svelte";
import { Button, TabPanel } from "@sendou/components";
import { shortStageName } from "@sendou/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "@sendou/in-game-lists/types";
import type { Snippet } from "svelte";
import { WebHaptics } from "web-haptics";
import Avatar from "#lib/components/Avatar.svelte";
import ModeImage from "#lib/components/ModeImage.svelte";
import StageImage from "#lib/components/StageImage.svelte";
import { stageName } from "#lib/modules/i18n/messages.ts";
import { m } from "#lib/paraglide/messages.js";
import { TAB_KEYS } from "./match-page-constants.ts";
import MatchTimeline, { type TimelineMap } from "./MatchTimeline.svelte";
const LONG_TEAM_NAME_THRESHOLD = 16;
interface Props {
teams: [ActionTabTeam, ActionTabTeam];
ownTeamId: number | null;
stageId: StageId;
mode: ModeShort;
withKo: boolean;
onSubmit?: (data: { winnerId: number; ko?: boolean }) => void;
isSubmitting?: boolean;
setEnding?: SetEndingData;
actionButtons?: Snippet;
secondaryAction?: Snippet;
}
let {
teams,
ownTeamId,
stageId,
mode,
withKo,
onSubmit,
isSubmitting,
setEnding,
actionButtons,
secondaryAction,
}: Props = $props();
const uid = $props.id();
const haptics = new WebHaptics();
let winnerId = $state<number | null>(null);
let isKo = $state(false);
let confirming = $state(false);
const canSubmit = $derived(winnerId !== null);
const isOnTeam = $derived(
ownTeamId != null &&
(teams[0].id === ownTeamId || teams[1].id === ownTeamId),
);
function selectWinner(selectedId: number) {
winnerId = selectedId;
const isEnemySelection = isOnTeam && selectedId !== ownTeamId;
if (isEnemySelection) {
void haptics.trigger([
{ duration: 40, intensity: 0.7 },
{ delay: 40, duration: 40, intensity: 0.7 },
{ delay: 40, duration: 40, intensity: 0.9 },
{ delay: 40, duration: 50, intensity: 0.6 },
]);
} else {
void haptics.trigger([
{ duration: 30 },
{ delay: 60, duration: 40, intensity: 1 },
]);
}
}
function submit() {
if (winnerId === null) return;
onSubmit?.({ winnerId, ko: withKo ? isKo : undefined });
}
function onSubmitPress() {
if (winnerId === null) return;
if (setEnding?.setEndingTeamIds.includes(winnerId)) {
confirming = true;
} else {
submit();
}
}
</script>
<TabPanel id={TAB_KEYS.ACTION}>
{#if confirming && winnerId !== null && setEnding}
{@render setEndingConfirmation(winnerId, setEnding)}
{:else}
<div class={["root", { withKo }]}>
<div class="title">{m.q_match_action_selectWinner()}</div>
{#if actionButtons}
<div class="actionButtons">{@render actionButtons()}</div>
{/if}
<div
role="radiogroup"
aria-label={m.q_match_action_selectWinner()}
class="selectionRow"
>
{@render teamRadioOption(teams[0], "alpha", "winner-radio-1")}
<StageImage
{stageId}
width={90}
class="stageImage"
containerClass="stageImageWrapper"
/>
<div class="stageLabel">
<ModeImage {mode} size={14} />
<span>{shortStageName(stageName(stageId))}</span>
</div>
{@render teamRadioOption(teams[1], "bravo", "winner-radio-2")}
</div>
{#if withKo}
<div class="ko">
<label class="koLabel">
<input
type="checkbox"
bind:checked={isKo}
data-testid="ko-checkbox"
/>
{m.q_match_action_ko()}
</label>
</div>
{/if}
<Button
variant="primary"
disabled={!canSubmit || isSubmitting}
onclick={onSubmitPress}
class="submit"
testId="report-score-button"
>
{m.common_actions_submit()}
</Button>
</div>
{/if}
{@render secondaryAction?.()}
</TabPanel>
{#snippet setEndingConfirmation(
confirmedWinnerId: number,
setEndingData: SetEndingData,
)}
{@const winnerSide = confirmedWinnerId === teams[0].id ? "ALPHA" : "BRAVO"}
{@const newMap = {
stageId,
mode,
timestamp: Date.now(),
winner: winnerSide,
rosters: setEndingData.currentRosters,
ko: withKo ? isKo : undefined,
} satisfies TimelineMap}
{@const updatedScore = {
alpha: setEndingData.score.alpha + (winnerSide === "ALPHA" ? 1 : 0),
bravo: setEndingData.score.bravo + (winnerSide === "BRAVO" ? 1 : 0),
}}
<div class="confirmationRoot">
<div class="confirmationMessage">
{m.q_match_action_confirmSetEnding()}
</div>
<MatchTimeline
teams={setEndingData.teams}
score={updatedScore}
maps={[...setEndingData.maps, newMap]}
/>
<div class="confirmationButtons">
<Button
variant="primary"
disabled={isSubmitting}
onclick={submit}
testId="confirm-set-end-button"
>
{m.common_actions_confirm()}
</Button>
<Button variant="outlined" onclick={() => (confirming = false)}>
{m.common_actions_back()}
</Button>
</div>
</div>
{/snippet}
{#snippet teamRadioOption(
team: ActionTabTeam,
areaClass: "alpha" | "bravo",
testId: string,
)}
{@const isSelected = winnerId === team.id}
{@const isOwnTeam = team.id === ownTeamId}
{@const hideLabel = ownTeamId === null}
{@const isLongName = team.name.length > LONG_TEAM_NAME_THRESHOLD}
<label
class={["teamRadioContainer", areaClass]}
data-testid={testId}
data-selected={isSelected ? "true" : undefined}
>
<input
type="radio"
class="srOnly"
name="{uid}-winner"
value={String(team.id)}
checked={isSelected}
disabled={isSubmitting}
aria-label={team.name}
onchange={() => selectWinner(team.id)}
/>
<span class={["teamRadio", { selected: isSelected }]}>
<span class={["checkCircle", { checkCircleSelected: isSelected }]}>
{#if isSelected}<Check size={14} />{/if}
</span>
<span class="teamAvatarInfo">
<Avatar url={team.avatar} identiconInput={team.name} size="xxs" />
<span class="teamInfo">
<span class={["teamName", { teamNameLong: isLongName }]}>
{team.name}
</span>
{#if !hideLabel}
<span
class={[
"teamLabel",
isOwnTeam ? "myTeamLabel" : "opponentLabel",
]}
>
{isOwnTeam
? m.q_match_action_myTeam()
: m.q_match_action_opponent()}
</span>
{/if}
</span>
</span>
</span>
</label>
{/snippet}
<style>
.root {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas:
"header header header"
"actions actions actions"
"selection selection selection"
"submit submit submit";
justify-items: center;
align-items: center;
gap: var(--s-5);
container-type: inline-size;
@container (max-width: 599px) {
grid-template-columns: 1fr;
grid-template-areas:
"header"
"actions"
"selection"
"submit";
}
}
.withKo {
grid-template-areas:
"header header header"
"actions actions actions"
"selection selection selection"
"ko ko ko"
"submit submit submit";
@container (max-width: 599px) {
grid-template-areas:
"header"
"actions"
"selection"
"ko"
"submit";
}
}
.title {
grid-area: header;
font-size: var(--font-md);
font-weight: var(--weight-semi);
text-align: center;
text-box: trim-start cap alphabetic;
}
.actionButtons {
grid-area: actions;
display: flex;
gap: var(--s-6);
margin-block-start: calc(-1 * var(--s-4));
}
.selectionRow {
grid-area: selection;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
grid-template-areas:
"alpha stage bravo"
". text .";
column-gap: var(--s-4);
row-gap: var(--s-1);
justify-items: center;
align-items: center;
width: 100%;
@container (max-width: 599px) {
column-gap: var(--s-2);
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas:
"stage alpha"
"stage bravo"
"text .";
}
}
.teamRadioContainer {
--label-margin: 0;
width: 100%;
height: 100%;
max-width: 250px;
@container (max-width: 599px) {
max-width: unset;
}
}
.alpha {
grid-area: alpha;
justify-self: end;
@container (max-width: 599px) {
justify-self: stretch;
align-self: end;
}
}
.bravo {
grid-area: bravo;
justify-self: start;
@container (max-width: 599px) {
justify-self: stretch;
align-self: start;
}
}
.selectionRow :global(.stageImageWrapper) {
grid-area: stage;
@container (max-width: 599px) {
align-self: stretch;
width: 90px;
}
}
.selectionRow :global(.stageImage) {
border-radius: var(--radius-box);
display: block;
@container (max-width: 599px) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.stageLabel {
grid-area: text;
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.ko {
grid-area: ko;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.root :global(.submit) {
grid-area: submit;
}
.checkCircle {
width: 24px;
height: 24px;
border-radius: 100%;
border: 2px solid var(--color-border-high);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.teamRadio {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-1-5) var(--s-3);
border-radius: var(--radius-field);
border: 2px solid var(--color-border);
cursor: pointer;
background-color: var(--color-bg-high);
min-width: 160px;
transition: background-color 0.15s;
height: 100%;
&:hover .checkCircle {
border-color: var(--color-accent-high);
}
@container (max-width: 599px) {
min-width: unset;
}
}
.selected {
background-color: var(--color-bg-higher);
}
.teamRadioContainer:has(input:focus-visible) .teamRadio {
outline: var(--focus-ring);
}
.srOnly {
border: 0;
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
white-space: nowrap;
}
.teamAvatarInfo {
display: flex;
align-items: center;
gap: var(--s-1-5);
min-width: 0;
}
.checkCircleSelected {
background-color: var(--color-accent-high);
border-color: var(--color-accent-high);
color: var(--color-text-inverse);
& :global(svg) {
stroke-width: 3px;
}
}
.teamInfo {
display: flex;
flex-direction: column;
line-height: 1.3;
min-width: 0;
}
.teamName {
font-weight: var(--weight-semi);
font-size: var(--font-sm);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.teamNameLong {
font-size: var(--font-2xs);
}
.teamLabel {
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
}
.myTeamLabel {
color: var(--color-success-high);
}
.opponentLabel {
color: var(--color-error-high);
}
.koLabel {
display: flex;
align-items: center;
gap: var(--s-1-5);
font-weight: var(--weight-semi);
cursor: pointer;
}
.confirmationRoot {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
}
.confirmationMessage {
font-weight: var(--weight-semi);
text-align: center;
color: var(--color-warning);
margin-block-end: var(--s-4);
}
.confirmationButtons {
display: flex;
gap: var(--s-3);
margin-block-start: var(--s-4);
}
</style>

View File

@@ -0,0 +1,232 @@
<script module lang="ts">
import { Check, X } from "@lucide/svelte";
import { Button, Popover } from "@sendou/components";
import Avatar from "#lib/components/Avatar.svelte";
import { m } from "#lib/paraglide/messages.js";
import { specialWeaponImageUrl } from "#lib/utils/urls.ts";
export { joinInfo, screenNotice };
export interface BannerHost {
name: string;
avatarUrl?: string;
}
</script>
<script lang="ts">
import type { ModeShort, StageId } from "@sendou/in-game-lists/types";
import type { Snippet } from "svelte";
// xxx: MultiMatchBanner, MatchBannerBottomRow, MatchBannerStartedAt and
// MatchBannerTimer are not ported yet — only the sendouq/tournament match
// pages reach them, the scrims usage does not
import ModeImage from "#lib/components/ModeImage.svelte";
import { modeShortName, stageName } from "#lib/modules/i18n/messages.ts";
import { stageBannerImageUrl } from "#lib/utils/urls.ts";
interface Props {
stageId: StageId;
mode: ModeShort;
screenLegal?: boolean;
joinPool?: string | null;
joinPass?: string | null;
host?: BannerHost | null;
children?: Snippet;
}
let {
stageId,
mode,
screenLegal,
joinPool,
joinPass,
host,
children,
}: Props = $props();
</script>
<div
class="banner"
style:--stage-img="url({stageBannerImageUrl(stageId)})"
data-testid="stage-banner"
>
<div class="map thickText" data-testid="banner-map-{mode}-{stageId}">
<ModeImage {mode} size={24} />
{modeShortName(mode)} {stageName(stageId)}
</div>
<div class="info thickText">{@render children?.()}</div>
{#if joinPool}
{@render joinInfo({ pool: joinPool, pass: joinPass, host })}
{/if}
{#if screenLegal !== undefined}
{@render screenNotice(screenLegal)}
{/if}
</div>
{#snippet joinInfo({
pool,
pass,
host,
}: {
pool: string;
pass?: string | null;
host?: BannerHost | null;
})}
<div class="joinInfo">
<div class="joinInfoItem">
<div class="joinInfoLabel">{m.q_match_pool()}</div>
<div class="joinInfoValue">{pool}</div>
</div>
{#if pass}
<div class="joinInfoItem">
<div class="joinInfoLabel">{m.q_match_password_short()}</div>
<div class="joinInfoValue" data-testid="room-pass">{pass}</div>
</div>
{/if}
{#if host}
<div class="joinInfoItem">
<div class="joinInfoLabel">{m.common_host()}</div>
<div class="joinInfoValue">
<Avatar
url={host.avatarUrl}
identiconInput={host.name}
size="xxs"
alt={host.name}
/>
</div>
</div>
{/if}
</div>
{/snippet}
{#snippet screenNotice(screenLegal: boolean)}
<span class="screenNoticeRoot">
<Popover>
{#snippet trigger(triggerProps)}
<Button
variant="minimal"
class="notice"
testId={screenLegal ? "screen-allowed" : "screen-banned"}
aria-label={screenLegal ? "Screen allowed" : "Screen banned"}
{...triggerProps}
>
<img
src="{specialWeaponImageUrl(19)}.avif"
width="24"
height="24"
alt=""
/>
{#if screenLegal}
<Check size={24} class="legalIcon" />
{:else}
<X size={24} class="illegalIcon" />
{/if}
</Button>
{/snippet}
{screenLegal
? m.q_match_screen_allowed({ special: m.weapons_SPECIAL_19() })
: m.q_match_screen_ban({ special: m.weapons_SPECIAL_19() })}
</Popover>
</span>
{/snippet}
<style>
.banner {
position: relative;
display: grid;
grid-template-columns: max-content 1fr;
grid-template-areas: "map info";
background-size: cover;
background-position: center;
background-repeat: no-repeat;
width: 100%;
height: var(--banner-height);
border-radius: var(--radius-box);
padding: var(--s-2-5);
background-image:
linear-gradient(
to top,
rgba(0, 0, 0, 0.6),
rgba(0, 0, 0, 0.6),
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.6),
rgba(0, 0, 0, 0.6)
),
var(--stage-img);
color: var(--color-text);
}
:global(html.light) .banner {
color: var(--color-text-inverse);
}
.info {
grid-area: info;
justify-self: flex-end;
}
.map {
grid-area: map;
display: flex;
gap: var(--s-1);
}
.screenNoticeRoot {
display: contents;
}
.screenNoticeRoot :global(.notice) {
position: absolute;
bottom: var(--s-2);
right: var(--s-2);
display: flex;
gap: var(--s-1);
align-items: center;
color: inherit;
font-size: inherit;
font-weight: inherit;
height: auto;
}
.joinInfo {
position: absolute;
bottom: var(--s-2);
left: var(--s-2);
display: flex;
gap: var(--s-4);
}
.joinInfoItem {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.joinInfoLabel {
text-transform: uppercase;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
opacity: 0.7;
}
.joinInfoValue {
font-size: var(--font-lg);
font-weight: var(--weight-bold);
letter-spacing: 1px;
}
.thickText {
font-size: var(--font-md);
font-weight: var(--weight-semi);
}
.screenNoticeRoot :global(.legalIcon) {
color: var(--color-success);
}
.screenNoticeRoot :global(.illegalIcon) {
color: var(--color-error);
}
</style>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<div class="root">{@render children()}</div>
<style>
.root {
--banner-height: 175px;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
container-type: inline-size;
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import TimePopover from "#lib/components/TimePopover.svelte";
const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
weekday: "short",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
};
interface Props {
time: Date;
}
let { time }: Props = $props();
</script>
<TimePopover date={time} options={FORMAT_OPTIONS} class="font-semi-bold" />

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { m } from "#lib/paraglide/messages.js";
interface Score {
alpha: number;
bravo: number;
isFinal: boolean;
count?: number;
bestOf?: boolean;
}
interface Props {
score?: Score;
children?: Snippet;
}
let { score, children }: Props = $props();
</script>
<div class="root">
{#if score}
{@render scoreDisplay(score)}
{:else}
<div></div>
{/if}
{@render children?.()}
</div>
{#snippet scoreDisplay(score: Score)}
<div class="values">
<div>
{score.alpha}-{score.bravo}
</div>
<div class="sub" data-testid={score.isFinal ? "match-final" : undefined}>
{#if score.isFinal}
{m.q_match_banner_final()}
{:else if score.count !== undefined}
{score.bestOf
? m.q_match_banner_bestOf({ count: score.count })
: m.q_match_banner_playAll({ count: score.count })}
{/if}
</div>
</div>
{/snippet}
<style>
.root {
display: flex;
justify-content: space-between;
padding-inline: var(--s-1-5);
}
.values {
display: flex;
gap: var(--s-2);
font-weight: var(--weight-semi);
}
.sub {
color: var(--color-text-high);
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children?: Snippet;
class?: string;
}
let { children, class: className }: Props = $props();
</script>
<div class={["root", className]}>{@render children?.()}</div>
<style>
.root {
display: flex;
flex-direction: column;
gap: var(--s-6);
}
</style>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
subtitle: string;
topRight?: Snippet;
}
let { children, subtitle, topRight }: Props = $props();
</script>
<div class="root">
<div>
<h2 class="title">{@render children()}</h2>
<div class="subtitle">{subtitle}</div>
</div>
{#if topRight}<div>{@render topRight()}</div>{/if}
</div>
<style>
.root {
display: flex;
justify-content: space-between;
}
.title {
font-size: var(--font-lg);
}
.subtitle {
font-weight: var(--weight-bold);
font-size: var(--font-xs);
color: var(--color-text-high);
}
</style>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { TabPanel } from "@sendou/components";
import type { Snippet } from "svelte";
import { TAB_KEYS } from "./match-page-constants.ts";
import MatchTimeline, {
type MatchTimelineProps,
} from "./MatchTimeline.svelte";
interface Props extends MatchTimelineProps {
children?: Snippet;
}
let { children, ...timelineProps }: Props = $props();
</script>
<TabPanel id={TAB_KEYS.RESULT}>
<MatchTimeline {...timelineProps} />
{@render children?.()}
</TabPanel>

View File

@@ -0,0 +1,275 @@
<script module lang="ts">
import type { CommonUser } from "#lib/server/kysely.ts";
export interface RosterTabMember extends CommonUser {
inGameName?: string | null;
}
export interface RosterTabTeam {
team?: {
id: number;
name: string;
url: string;
avatar?: string;
};
defaultName?: string;
members: Array<RosterTabMember>;
}
</script>
<script lang="ts">
import { TabPanel } from "@sendou/components";
import invariant from "@sendou/utils/invariant";
import Avatar from "#lib/components/Avatar.svelte";
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
import { TAB_KEYS } from "./match-page-constants.ts";
// xxx: the active-roster editing flow (canEditSubbedOut/defaultIsEditing/
// onSubbedOutChange/minMembersPerTeam), subbed-out popover, member tiers,
// weapon pools and tournament seeds are not ported yet — the scrims usage
// never reaches them (sendouq/tournament-only)
interface Props {
teams: [RosterTabTeam, RosterTabTeam];
}
let { teams }: Props = $props();
const userCardContext = getUserCardContext();
function defaultNameOf(team: RosterTabTeam) {
invariant(team.defaultName, "team or defaultName must be provided");
return team.defaultName;
}
</script>
<TabPanel id={TAB_KEYS.ROSTERS}>
<div class="rosters">
{@render teamRoster(teams[0], "alpha")}
<div class="rostersDivider"></div>
{@render teamRoster(teams[1], "bravo")}
</div>
</TabPanel>
{#snippet teamRoster(team: RosterTabTeam, side: "alpha" | "bravo")}
<div class="stack xxs rosterColumn">
{@render teamHeader(team, side)}
{#if team.members.length > 0}
<ul class="rosterMembers">
{#each team.members as member (member.id)}
<li class="memberGrid">
{@render rosterMemberLink(member)}
<div class="memberSecondRow">
<div class="memberTier"></div>
</div>
</li>
{/each}
</ul>
{/if}
</div>
{/snippet}
{#snippet teamHeader(team: RosterTabTeam, side: "alpha" | "bravo")}
{#if team.team}
<a href={team.team.url} class="stack horizontal sm">
<Avatar
url={team.team.avatar}
identiconInput={team.team.name}
size="sm"
/>
<div class="stack justify-center line-height-tight teamHeaderText">
<h2
class="text-main-forced font-bold teamNameHeading"
title={team.team.name}
>
{team.team.name}
</h2>
{@render teamHeaderMeta()}
</div>
</a>
{:else}
{@const defaultName = defaultNameOf(team)}
<div class="stack horizontal sm">
<div class="teamAvatar" data-side={side}></div>
<div class="stack justify-center line-height-tight teamHeaderText">
<h2
class="text-main-forced font-bold teamNameHeading"
title={defaultName}
>
{defaultName}
</h2>
{@render teamHeaderMeta()}
</div>
</div>
{/if}
{/snippet}
{#snippet teamHeaderMeta()}
<!-- xxx: tournament seed + sendouq tier render here once those features migrate -->
<div class="stack xs horizontal items-center text-lighter"></div>
{/snippet}
{#snippet rosterMemberLink(member: RosterTabMember)}
{@const sentiment = userCardContext
?.userCards()
?.get(member.id)?.privateNote?.sentiment}
<UserCard userId={member.id}>
<span class="memberLink">
<NoteAvatar {sentiment} size="xs">
<Avatar user={member} size="xxs" />
</NoteAvatar>
<div class="memberNameStack">
<span>{member.username}</span>
{#if member.inGameName}
<span class="memberInGameName">{member.inGameName}</span>
{/if}
</div>
</span>
</UserCard>
{/snippet}
<style>
.rosters {
display: flex;
flex-direction: column;
gap: var(--s-8);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
width: max-content;
max-width: 100%;
margin-inline: auto;
}
.rostersDivider {
display: none;
}
@container (width >= 640px) {
.rosters {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: var(--s-4);
width: auto;
max-width: none;
margin-inline: 0;
}
.rosterColumn {
margin-inline: auto;
width: max-content;
max-width: 100%;
min-width: 0;
}
.rostersDivider {
display: block;
background-color: var(--color-border);
width: 1px;
align-self: stretch;
}
}
.teamHeaderText {
min-width: 0;
}
.teamNameHeading {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
}
.rosterMembers {
position: relative;
padding-inline-start: 34px;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--s-2-5);
margin-top: var(--s-2);
&::before {
content: "";
position: absolute;
inset-inline-start: 21px;
top: -8px;
bottom: 0;
width: 3px;
background-color: var(--color-border-high);
opacity: 0.3;
border-radius: 0 0 var(--radius-field) var(--radius-field);
}
}
.memberGrid {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s-1);
min-width: 0;
& > * {
max-width: 100%;
min-width: 0;
}
}
.memberLink {
display: flex;
align-items: center;
gap: var(--s-2);
max-width: 100%;
min-width: 0;
}
.memberSecondRow {
display: flex;
align-items: center;
gap: var(--s-2);
}
.memberNameStack {
display: flex;
flex-direction: column;
line-height: 1.2;
min-width: 0;
& > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
}
.memberInGameName {
font-size: var(--font-2xs);
color: var(--color-text-high);
font-weight: var(--weight-semi);
}
.memberTier {
display: flex;
justify-content: center;
width: 24px;
flex-shrink: 0;
}
.teamAvatar {
border-radius: var(--radius-avatar);
width: 44px;
height: 44px;
flex-shrink: 0;
&[data-side="alpha"] {
background-color: var(--color-accent);
}
&[data-side="bravo"] {
background-color: var(--color-second);
}
}
</style>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { BarChart3, Key, ScrollText, Tally5, Users } from "@lucide/svelte";
import { Tab, TabList, Tabs } from "@sendou/components";
import invariant from "@sendou/utils/invariant";
import type { Component, Snippet } from "svelte";
import { searchParamsState } from "#lib/modules/search-params/search-params-state.svelte.ts";
import { m } from "#lib/paraglide/messages.js";
import type { MatchTabsKey } from "./match-page-constants.ts";
import { matchPageSearchParams } from "./match-page-search-params.ts";
const TAB_ICONS: Record<MatchTabsKey, Component> = {
rosters: Users,
action: Tally5,
result: ScrollText,
stats: BarChart3,
admin: Key,
};
const TAB_LABELS: Record<MatchTabsKey, () => string> = {
rosters: m.q_match_tabs_rosters,
action: m.q_match_tabs_action,
result: m.q_match_tabs_result,
stats: m.q_match_tabs_stats,
admin: m.common_pages_admin,
};
interface Props {
children: Snippet;
tabs: Array<MatchTabsKey>;
/** Tabs that should show a warning-colored alert icon to draw attention. */
alertTabs?: Array<MatchTabsKey>;
}
let { children, tabs, alertTabs }: Props = $props();
const params = searchParamsState(matchPageSearchParams);
const currentTab = $derived.by(() => {
const tab = tabs.find((tab) => params.current.tab === tab) ?? tabs.at(0);
invariant(tab);
return tab;
});
</script>
<div class="root">
<Tabs
selectedKey={currentTab}
onSelectionChange={(key) => params.set({ tab: key as MatchTabsKey })}
disappearing={false}
padded={false}
>
<TabList>
{#each tabs as tab (tab)}
{@const Icon = TAB_ICONS[tab]}
<Tab id={tab} alert={alertTabs?.includes(tab)}>
{#snippet icon()}<Icon />{/snippet}
{TAB_LABELS[tab]()}
</Tab>
{/each}
</TabList>
{@render children()}
</Tabs>
</div>
<style>
.root :global([class*="tabPanel"]) {
background-color: var(--color-bg-high);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-6) var(--s-4);
}
</style>

View File

@@ -0,0 +1,458 @@
<script module lang="ts">
import type { ModeShort, StageId } from "@sendou/in-game-lists/types";
import type { CommonUser } from "#lib/server/kysely.ts";
// xxx: the ingested-scoreboard section (per-map stats tables + game timeline
// charts), weapon pools, pick/ban rows and SP change rows are not ported yet —
// the scrims usage never reaches them (sendouq/tournament/scanner-only)
type MatchSide = "ALPHA" | "BRAVO";
export interface TimelineTeam {
name: string;
avatar?: string;
}
export interface TimelineMap {
stageId: StageId;
mode: ModeShort;
timestamp: number;
winner: MatchSide;
rosters: {
alpha: CommonUser[];
bravo: CommonUser[];
};
/** Whether the game ended in a knockout. Undefined if not collected. */
ko?: boolean;
}
export interface MatchTimelineProps {
teams: { alpha: TimelineTeam; bravo: TimelineTeam };
score?: { alpha: number; bravo: number };
maps: TimelineMap[];
/** When true, render only the team + score header (no per-map rows). */
compact?: boolean;
/** When true, the match is still in progress; renders a small LIVE label under the score. */
isOngoing?: boolean;
}
</script>
<script lang="ts">
import { RefreshCcw } from "@lucide/svelte";
import { Button, Popover } from "@sendou/components";
import { shortStageName } from "@sendou/in-game-lists/stage-ids";
import type { Snippet } from "svelte";
import Avatar from "#lib/components/Avatar.svelte";
import LocaleTime from "#lib/components/LocaleTime.svelte";
import ModeImage from "#lib/components/ModeImage.svelte";
import StageImage from "#lib/components/StageImage.svelte";
import { stageName } from "#lib/modules/i18n/messages.ts";
import { m } from "#lib/paraglide/messages.js";
import {
type InferredSubstitution,
inferSubstitutions,
} from "./match-page-utils.ts";
const LONG_TEAM_NAME_THRESHOLD = 16;
let {
teams,
score,
maps,
compact = false,
isOngoing = false,
}: MatchTimelineProps = $props();
</script>
<div class="root">
{@render timelineHeader()}
{#if !compact}
{#each maps as map, i (`${map.timestamp}-${i}`)}
{@const previousMap = maps[i - 1]}
{@const substitutions = previousMap
? inferSubstitutions(previousMap.rosters, map.rosters)
: []}
<div class="contents">
{#each substitutions as substitution (`${substitution.playerOut.id}-${substitution.playerIn.id}`)}
{@render timelineSubstitutionRow(substitution)}
{/each}
{@render timelineMapRow(map)}
</div>
{/each}
{/if}
</div>
{#snippet timelineHeader()}
{@const initialRosters = maps[0]?.rosters}
<div class="header">
<div class="headerTeam">
<div
class={[
"headerTeamName",
{
headerTeamNameLong:
teams.alpha.name.length > LONG_TEAM_NAME_THRESHOLD,
},
]}
>
{teams.alpha.name}
</div>
{#if initialRosters}
<div class="headerAvatars">
{#each initialRosters.alpha as user (user.id)}
<Avatar {user} size="xxs" />
{/each}
</div>
{/if}
</div>
<div class="headerScore">
{#if score}
<span class="headerScoreValue">
{score.alpha}-{score.bravo}
</span>
{/if}
{#if isOngoing}
<span class="headerScoreLive">
{m.q_match_timeline_ongoing()}
</span>
{/if}
</div>
<div class="headerTeam headerTeamBravo">
<div
class={[
"headerTeamName",
{
headerTeamNameLong:
teams.bravo.name.length > LONG_TEAM_NAME_THRESHOLD,
},
]}
>
{teams.bravo.name}
</div>
{#if initialRosters}
<div class="headerAvatars">
{#each initialRosters.bravo as user (user.id)}
<Avatar {user} size="xxs" />
{/each}
</div>
{/if}
</div>
</div>
{/snippet}
{#snippet timelineMapRow(map: TimelineMap)}
<div class="mapEvent">
<div class="mapSide">
{@render sideResult(
map.winner === "ALPHA" ? "WIN" : "LOSS",
map.ko && map.winner === "ALPHA",
)}
</div>
<div class="mapCenter">
<LocaleTime
date={new Date(map.timestamp)}
options={{ hour: "numeric", minute: "numeric" }}
class="mapTimestamp"
/>
<StageImage stageId={map.stageId} width={80} class="mapStageImage" />
<div class="mapLabel">
<ModeImage mode={map.mode} size={14} />
<span>{shortStageName(stageName(map.stageId))}</span>
</div>
</div>
<div class="mapSide mapSideBravo">
{@render sideResult(
map.winner === "BRAVO" ? "WIN" : "LOSS",
map.ko && map.winner === "BRAVO",
)}
</div>
</div>
{/snippet}
{#snippet sideResult(result: "WIN" | "LOSS", isKo: boolean | undefined)}
<div class="sideResult">
<div class="resultHeaderGroup">
<div class="resultHeader">
<span
class={[
"resultLabel",
result === "WIN" ? "text-success" : "text-error",
]}
>
{result === "WIN"
? m.q_match_timeline_win()
: m.q_match_timeline_loss()}
</span>
{#if isKo}
<span class="resultPoints">{m.q_match_action_ko()}</span>
{/if}
</div>
</div>
</div>
{/snippet}
{#snippet explainerIcon(icon: Snippet, description: string)}
<Popover>
{#snippet trigger(triggerProps)}
<Button
variant="minimal"
class="explainerTrigger"
aria-label={description}
{...triggerProps}
>
{@render icon()}
</Button>
{/snippet}
{description}
</Popover>
{/snippet}
{#snippet substitutionIcon()}
<RefreshCcw size={32} class="eventIcon" />
{/snippet}
{#snippet timelineSubstitutionRow(substitution: InferredSubstitution)}
<div class="eventRow">
<div class="eventAlpha">
{#if substitution.side === "ALPHA"}
{@render substitutionDetail(substitution)}
{/if}
</div>
<div class="subCenter">
{@render explainerIcon(
substitutionIcon,
m.q_match_timeline_explainer_substitution(),
)}
</div>
<div>
{#if substitution.side === "BRAVO"}
{@render substitutionDetail(substitution)}
{/if}
</div>
</div>
{/snippet}
{#snippet substitutionDetail(substitution: InferredSubstitution)}
<div class="subDetail">
<span class="subLabelOut">{m.q_match_timeline_out()}</span>
<div class="stack horizontal items-center sm">
<Avatar user={substitution.playerOut} size="xxxs" />
<span class="subPlayerName">
{substitution.playerOut.username}
</span>
</div>
<span class="subLabelIn">{m.q_match_timeline_in()}</span>
<div class="stack horizontal items-center sm">
<Avatar user={substitution.playerIn} size="xxxs" />
<span class="subPlayerName">
{substitution.playerIn.username}
</span>
</div>
</div>
{/snippet}
<style>
.root {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
row-gap: var(--s-6);
column-gap: var(--s-4);
align-items: center;
width: 100%;
}
.header {
display: contents;
}
.headerTeam {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s-1-5);
}
.headerTeamBravo {
align-items: flex-start;
}
.headerTeamName {
font-weight: var(--weight-bold);
font-size: var(--font-md);
text-box: trim-start cap alphabetic;
overflow-wrap: anywhere;
}
.headerTeamNameLong {
font-size: var(--font-xs);
}
.headerAvatars {
display: flex;
gap: var(--s-1);
}
.headerScore {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.headerScoreValue {
font-size: var(--font-xl);
font-weight: var(--weight-extra);
line-height: 1;
}
.headerScoreLive {
margin-top: var(--s-1);
font-size: var(--font-3xs);
font-weight: var(--weight-bold);
letter-spacing: 0.1em;
color: var(--color-error);
}
.mapEvent {
display: contents;
}
.mapSide {
display: grid;
grid-template-rows: auto 1fr auto;
align-self: stretch;
container: weapon-pool / inline-size;
}
.mapCenter {
display: grid;
grid-template-rows: auto 1fr auto;
justify-items: center;
gap: var(--s-1);
}
.mapCenter :global(.mapTimestamp) {
font-size: var(--font-3xs);
color: var(--color-text-high);
font-weight: var(--weight-semi);
}
.mapCenter :global(.mapStageImage) {
border-radius: var(--radius-box);
}
.mapLabel {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-3xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.sideResult {
grid-row: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-2-5);
.mapSideBravo & {
justify-self: start;
}
.mapSide:not(.mapSideBravo) & {
justify-self: end;
}
}
.resultHeaderGroup {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
}
.resultHeader {
display: flex;
align-items: baseline;
gap: var(--s-1);
}
.resultLabel {
font-size: var(--font-sm);
font-weight: var(--weight-extra);
text-transform: uppercase;
}
.resultPoints {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text);
}
.eventRow {
display: contents;
}
.eventAlpha {
justify-self: end;
}
.subCenter {
display: flex;
justify-content: center;
align-items: center;
}
.subCenter :global(.eventIcon) {
color: var(--color-text-high);
background-color: var(--color-bg-higher);
border-radius: var(--radius-full);
padding: var(--s-1);
}
.subCenter :global(.explainerTrigger) {
display: flex;
align-items: center;
justify-content: center;
height: auto;
width: auto;
padding: 0;
border: none;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
border-radius: var(--radius-full);
cursor: pointer;
}
.subDetail {
display: grid;
grid-template-columns: max-content 1fr;
align-items: center;
row-gap: var(--s-1);
column-gap: var(--s-3);
}
.subLabelOut {
color: var(--color-error);
font-weight: var(--weight-bold);
font-size: var(--font-3xs);
text-transform: uppercase;
}
.subLabelIn {
color: var(--color-success);
font-weight: var(--weight-bold);
font-size: var(--font-3xs);
text-transform: uppercase;
}
.subPlayerName {
font-weight: var(--weight-semi);
font-size: var(--font-xs);
}
</style>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { ChevronUp } from "@lucide/svelte";
import { Button } from "@sendou/components";
import type { Snippet } from "svelte";
interface Props {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
collapsedLabel: string;
collapsedIcon?: Snippet;
expandedAriaLabel?: string;
/**
* Always-open variant used when this is the only content in the tab (no
* primary action to sit underneath). Hides the collapse toggle and drops the
* striped footer styling.
*/
standalone?: boolean;
/**
* Forces the expanded state and hides the collapse toggle while keeping the
* footer styling. Used when the expanded content is small enough that
* collapsing brings no benefit.
*/
alwaysOpen?: boolean;
children: Snippet;
}
/**
* Generic panel hosting follow-up match actions (e.g. scrim map list
* management). Defaults to a striped footer attached beneath the primary
* action card; pass `standalone` when it is the only tab content.
*/
let {
isOpen,
onOpenChange,
collapsedLabel,
collapsedIcon,
expandedAriaLabel,
standalone,
alwaysOpen,
children,
}: Props = $props();
const collapsible = $derived(!standalone && !alwaysOpen);
</script>
{#if !isOpen && collapsible}
<div class={["collapsed", { footer: !standalone }]}>
<Button
variant="minimal"
size="small"
icon={collapsedIcon}
onclick={() => onOpenChange(true)}
>
{collapsedLabel}
</Button>
</div>
{:else}
<div class={["expanded", { footer: !standalone }]}>
{#if collapsible}
<Button
variant="minimal"
size="miniscule"
onclick={() => onOpenChange(false)}
class="collapseButton"
aria-label={expandedAriaLabel ?? collapsedLabel}
>
{#snippet icon()}<ChevronUp size={22} />{/snippet}
</Button>
{/if}
{@render children()}
</div>
{/if}
<style>
.collapsed {
display: flex;
justify-content: center;
}
.collapsed.footer {
background-color: var(--color-bg-higher);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-2);
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
}
.expanded {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
container-type: inline-size;
position: relative;
}
.expanded.footer {
background-color: var(--color-bg-higher);
border-radius: 0 0 var(--radius-box) var(--radius-box);
padding: var(--s-4);
margin: var(--s-4) calc(-1 * var(--s-4)) calc(-1 * var(--s-6));
}
.expanded :global(.collapseButton) {
position: absolute;
inset-block-start: var(--s-2);
inset-inline-end: var(--s-3);
}
.expanded :global(.collapseButton svg) {
min-width: 22px;
max-width: 22px;
}
</style>

View File

@@ -0,0 +1,17 @@
export const TAB_KEYS = {
ROSTERS: "rosters",
ACTION: "action",
RESULT: "result",
STATS: "stats",
ADMIN: "admin",
} as const;
export const MATCH_PAGE_TABS = [
"rosters",
"action",
"result",
"stats",
"admin",
] as const;
export type MatchTabsKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS];

View File

@@ -0,0 +1,19 @@
import { describe, expect, test } from "vitest";
import { assertRoundTrips } from "#lib/modules/search-params/search-params-test-utils.ts";
import { matchPageSearchParams } from "./match-page-search-params.ts";
describe("matchPageSearchParams", () => {
test("round-trips representative values", () => {
assertRoundTrips(matchPageSearchParams, {
tab: [null, "rosters", "action", "result", "stats", "admin"],
});
});
test("decodes garbage to defaults", () => {
const parsed = matchPageSearchParams.parse(
new URLSearchParams("tab=nonsense"),
);
expect(parsed).toEqual({ tab: null });
});
});

View File

@@ -0,0 +1,8 @@
import * as v from "valibot";
import * as SearchParams from "#lib/modules/search-params/search-params.ts";
import { SP } from "#lib/modules/search-params/search-params.ts";
import { MATCH_PAGE_TABS } from "./match-page-constants.ts";
export const matchPageSearchParams = SearchParams.define({
tab: SP.param(v.nullable(v.picklist(MATCH_PAGE_TABS)), {}),
});

View File

@@ -0,0 +1,142 @@
import { describe, expect, test } from "vitest";
import type { CommonUser } from "#lib/server/kysely.ts";
import { inferSubstitutions, resolveRoomPass } from "./match-page-utils.ts";
function user(id: number): CommonUser {
return {
id,
username: `user${id}`,
discordId: `discord${id}`,
discordAvatar: null,
customUrl: null,
customAvatarUrl: null,
};
}
describe("inferSubstitutions", () => {
test("returns an empty array when rosters are unchanged", () => {
const rosters = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6), user(7), user(8)],
};
expect(inferSubstitutions(rosters, rosters)).toEqual([]);
});
test("detects a single substitution on alpha", () => {
const previous = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6), user(7), user(8)],
};
const current = {
alpha: [user(1), user(2), user(3), user(9)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(4), playerIn: user(9) },
]);
});
test("detects substitutions on both sides in the same map transition", () => {
const previous = {
alpha: [user(1), user(2)],
bravo: [user(3), user(4)],
};
const current = {
alpha: [user(1), user(10)],
bravo: [user(11), user(4)],
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(2), playerIn: user(10) },
{ side: "BRAVO", playerOut: user(3), playerIn: user(11) },
]);
});
test("pairs multiple substitutions on the same side by roster order", () => {
const previous = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6)],
};
const current = {
alpha: [user(1), user(10), user(3), user(11)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(2), playerIn: user(10) },
{ side: "ALPHA", playerOut: user(4), playerIn: user(11) },
]);
});
test("ignores unpaired leavers when no new player joined", () => {
const previous = {
alpha: [user(1), user(2), user(3), user(4)],
bravo: [user(5), user(6)],
};
const current = {
alpha: [user(1), user(2), user(3)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([]);
});
test("ignores unpaired joiners when no player left", () => {
const previous = {
alpha: [user(1), user(2), user(3)],
bravo: [user(5), user(6)],
};
const current = {
alpha: [user(1), user(2), user(3), user(9)],
bravo: previous.bravo,
};
expect(inferSubstitutions(previous, current)).toEqual([]);
});
test("treats players switching sides as separate substitutions on each side", () => {
const previous = {
alpha: [user(1), user(2)],
bravo: [user(3), user(4)],
};
const current = {
alpha: [user(3), user(4)],
bravo: [user(1), user(2)],
};
expect(inferSubstitutions(previous, current)).toEqual([
{ side: "ALPHA", playerOut: user(1), playerIn: user(3) },
{ side: "ALPHA", playerOut: user(2), playerIn: user(4) },
{ side: "BRAVO", playerOut: user(3), playerIn: user(1) },
{ side: "BRAVO", playerOut: user(4), playerIn: user(2) },
]);
});
});
describe("resolveRoomPass", () => {
test("returns a 4-digit password", () => {
const pass = resolveRoomPass(12345);
expect(pass).toMatch(/^\d{4}$/);
});
test("returns deterministic password for a given numeric seed", () => {
const pass1 = resolveRoomPass(12345);
const pass2 = resolveRoomPass(12345);
expect(pass1).toBe(pass2);
});
test("returns deterministic password for a given string seed", () => {
const pass1 = resolveRoomPass("test-seed");
const pass2 = resolveRoomPass("test-seed");
expect(pass1).toBe(pass2);
});
test("returns different passwords for different seeds", () => {
const pass1 = resolveRoomPass(1);
const pass2 = resolveRoomPass(2);
expect(pass1).not.toBe(pass2);
});
});

View File

@@ -0,0 +1,83 @@
import { seededRandom } from "@sendou/utils/random";
import * as R from "remeda";
import type { CommonUser } from "#lib/server/kysely.ts";
type MatchSide = "ALPHA" | "BRAVO";
type Rosters = {
alpha: CommonUser[];
bravo: CommonUser[];
};
export interface InferredSubstitution {
side: MatchSide;
playerOut: CommonUser;
playerIn: CommonUser;
}
/**
* Compares the rosters of two consecutive maps and pairs up any
* players that dropped from a side with new players that joined the same side.
* The pairs are returned in roster order, so the first player out is paired with
* the first new player in. When the counts don't match, unpaired players are ignored.
*/
export function inferSubstitutions(
previousRosters: Rosters,
currentRosters: Rosters,
): InferredSubstitution[] {
const result: InferredSubstitution[] = [];
for (const side of ["alpha", "bravo"] as const) {
const prevIds = new Set(previousRosters[side].map((u) => u.id));
const currIds = new Set(currentRosters[side].map((u) => u.id));
const out = previousRosters[side].filter((u) => !currIds.has(u.id));
const inn = currentRosters[side].filter((u) => !prevIds.has(u.id));
for (const [playerOut, playerIn] of R.zip(out, inn)) {
result.push({
side: side === "alpha" ? "ALPHA" : "BRAVO",
playerOut,
playerIn,
});
}
}
return result;
}
const NUM_MAP = {
"1": ["1", "2", "4"],
"2": ["2", "1", "3", "5"],
"3": ["3", "2", "6"],
"4": ["4", "1", "5", "7"],
"5": ["5", "2", "4", "6", "8"],
"6": ["6", "3", "5", "9"],
"7": ["7", "4", "8"],
"8": ["8", "7", "5", "9", "0"],
"9": ["9", "6", "8"],
"0": ["0", "8"],
};
/**
* Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed.
*
* Given the same seed, this function will always return the same password.
*/
export function resolveRoomPass(seed: number | string) {
let pass = "5";
for (let i = 0; i < 3; i++) {
const { seededShuffle } = seededRandom(`${seed}-${i}`);
const key = pass[i] as keyof typeof NUM_MAP;
const opts = NUM_MAP[key];
const next = seededShuffle(opts)[0];
pass += next;
}
// prevent 5555 since many use it as a default pass
// making it a bit more common guess
if (pass === "5555") return "5800";
return pass;
}

View File

@@ -4,11 +4,14 @@
* so client code can import the types without touching server modules.
*/
import type { ScrimFilters } from "#lib/features/scrims/scrims-types.ts";
export type XRankPlacementRegion = "WEST" | "JPN";
export interface UserPreferences {
disableBuildAbilitySorting?: boolean;
disallowScrimPickupsFromUntrusted?: boolean;
defaultScrimsFilters?: ScrimFilters;
/**
* What time format the user prefers?
*

View File

@@ -0,0 +1,24 @@
/**
* Client-side chat UI state shared between the layout (sidebar/mobile panel)
* and pages that carry a chat room (e.g. a booked scrim). Written only from
* client-side lifecycles, so the module-level state can't leak between
* server-rendered requests.
*/
class ChatUiState {
/** Whether the desktop chat sidebar is open. */
sidebarOpen = $state(false);
/** The room the current route belongs to (a scrim page sets this on mount). */
routeChatRoomId = $state<number | null>(null);
/** The room opened in the sidebar's single-room view. */
openRoomId = $state<number | null>(null);
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
if (this.sidebarOpen && this.routeChatRoomId !== null) {
this.openRoomId = this.routeChatRoomId;
}
}
}
export const chatUi = new ChatUiState();

View File

@@ -0,0 +1,152 @@
import { error } from "@sveltejs/kit";
import * as v from "valibot";
import { getUser, requireUser } from "#lib/features/auth/user.server.ts";
import * as Events from "#lib/server/events.ts";
import { id } from "#lib/utils/schemas.ts";
import { command, query } from "$app/server";
import { CHAT } from "./chat-constants.ts";
import { publishChatRoom } from "./chat.server.ts";
import { canSendToRoom, roomLifecycle } from "./chat-utils.ts";
import * as ChatRepository from "./ChatRepository.server.ts";
/**
* A room's message stream: yields a fresh snapshot (messages, author display
* info, lifecycle) whenever anything about the room changes. Membership is
* checked server-side — knowing a room id is not enough to read it.
*/
export const getChatRoom = query.live(
v.object({ chatRoomId: id }),
async function* ({ chatRoomId }) {
const user = requireUser();
await requireRoomMember(chatRoomId, user);
yield await roomSnapshot(chatRoomId);
for await (const _ of Events.subscribe(
Events.chatRoomChannel(chatRoomId),
)) {
if (!(await ChatRepository.findRoomById(chatRoomId))) {
return;
}
yield await roomSnapshot(chatRoomId);
}
},
);
async function roomSnapshot(chatRoomId: number) {
const room = await ChatRepository.findRoomById(chatRoomId);
if (!room) error(404);
const messages = await ChatRepository.findMessagesByRoomId(chatRoomId);
const users = await ChatRepository.findChatUsersByUserIds([
...new Set(
messages
.map((message) => message.userId)
.filter((userId) => userId !== null),
),
]);
return {
roomId: chatRoomId,
messages,
users,
lifecycle: roomLifecycle(room.inactiveAt),
};
}
/** The user's chat rooms with unseen counts, streamed live. */
export const getChatRooms = query.live(async function* () {
const user = getUser();
if (!user) {
yield { rooms: [] };
return;
}
yield await roomsSnapshot(user.id);
for await (const _ of Events.subscribe(
Events.chatRoomsOfUserChannel(user.id),
)) {
yield await roomsSnapshot(user.id);
}
});
async function roomsSnapshot(userId: number) {
const rooms = await ChatRepository.findRoomsOfUser(userId);
return {
rooms: rooms.map((room) => ({
...room,
unseenCount: room.unseenCount ?? 0,
lifecycle: roomLifecycle(room.inactiveAt),
})),
};
}
export const sendChatMessage = command(
v.object({
chatRoomId: id,
contents: v.pipe(
v.string(),
v.trim(),
v.minLength(1),
v.maxLength(CHAT.MESSAGE_MAX_LENGTH),
),
}),
async ({ chatRoomId, contents }) => {
const user = requireUser();
const room = await requireRoomMember(chatRoomId, user);
if (!canSendToRoom(roomLifecycle(room.inactiveAt))) {
error(400, "Room is archived");
}
const message = await ChatRepository.insertMessage({
chatRoomId,
userId: user.id,
contents,
});
// the sender has obviously seen their own message
await ChatRepository.upsertRead({
chatRoomId,
userId: user.id,
lastSeenMessageId: message.id,
});
await publishChatRoom(chatRoomId);
},
);
/** Moves the reader's unseen marker up to the given message. */
export const markChatRoomRead = command(
v.object({ chatRoomId: id, lastSeenMessageId: id }),
async ({ chatRoomId, lastSeenMessageId }) => {
const user = requireUser();
await requireRoomMember(chatRoomId, user);
await ChatRepository.upsertRead({
chatRoomId,
userId: user.id,
lastSeenMessageId,
});
// other tabs of the same user drop their unseen badge too
Events.publish(Events.chatRoomsOfUserChannel(user.id));
},
);
async function requireRoomMember(
chatRoomId: number,
user: { id: number; roles: string[] },
) {
const room = await ChatRepository.findRoomById(chatRoomId);
if (!room) error(404, "Chat room not found");
const memberIds = await ChatRepository.findRoomMemberIds(chatRoomId);
if (!memberIds.includes(user.id) && !user.roles.includes("STAFF")) {
error(403, "Not a member of this chat room");
}
return room;
}

View File

@@ -0,0 +1,318 @@
<script lang="ts">
import { SendHorizontal } from "@lucide/svelte";
import { Button } from "@sendou/components";
import Avatar from "#lib/components/Avatar.svelte";
import { m } from "#lib/paraglide/messages.js";
import { getLocale } from "#lib/paraglide/runtime.js";
import { databaseTimestampToJavascriptTimestamp } from "#lib/utils/dates.ts";
import { CHAT } from "../chat-constants.ts";
import type { ChatMessageContext, ChatSystemMessageType } from "../chat-types.ts";
import { getChatRoom, markChatRoomRead, sendChatMessage } from "../chat.remote.ts";
interface Props {
chatRoomId: number;
class?: string;
messagesContainerClass?: string;
}
let { chatRoomId, class: className, messagesContainerClass }: Props = $props();
const room = $derived(getChatRoom({ chatRoomId }));
const snapshot = $derived(room.current);
let inputValue = $state("");
let sendPending = $state(false);
let messagesContainer = $state<HTMLDivElement | null>(null);
let stickToBottom = $state(true);
let unseenBelowScroll = $state(false);
const canSend = $derived(snapshot ? snapshot.lifecycle !== "ARCHIVED" : false);
const sendingDisabled = $derived(!canSend || !room.connected || sendPending);
function systemMessageText(message: {
type?: ChatSystemMessageType | null;
context?: ChatMessageContext | null;
}) {
const name = message.context?.name ?? "";
switch (message.type) {
case "MAP_REPLAYED":
return m.common_chat_systemMsg_mapReplayed({ name });
case "MAP_PICKED":
return m.common_chat_systemMsg_mapPicked({ name });
default:
return null;
}
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
const contents = inputValue.trim();
if (contents.length === 0) return;
sendPending = true;
try {
await sendChatMessage({ chatRoomId, contents });
inputValue = "";
stickToBottom = true;
} catch {
// keep the unsent text in the input so the user can retry
} finally {
sendPending = false;
}
}
function formatTimestamp(createdAt: number) {
const timestamp = databaseTimestampToJavascriptTimestamp(createdAt);
const moreThanDayAgo = Date.now() - timestamp > 24 * 60 * 60 * 1000;
return new Intl.DateTimeFormat(
getLocale(),
moreThanDayAgo
? { day: "numeric", month: "numeric", hour: "numeric", minute: "numeric" }
: { hour: "numeric", minute: "numeric" },
).format(new Date(timestamp));
}
function handleScroll() {
if (!messagesContainer) return;
const distanceFromBottom =
messagesContainer.scrollHeight -
messagesContainer.scrollTop -
messagesContainer.clientHeight;
stickToBottom = distanceFromBottom < 24;
if (stickToBottom) {
unseenBelowScroll = false;
}
}
function scrollToBottom() {
if (!messagesContainer) return;
messagesContainer.scrollTop = messagesContainer.scrollHeight;
stickToBottom = true;
unseenBelowScroll = false;
}
const lastMessageId = $derived(snapshot?.messages.at(-1)?.id ?? null);
let lastMarkedRead: number | null = null;
// new messages: follow the conversation when pinned to the bottom, otherwise
// surface the "new messages" affordance; reading marks the room seen
$effect(() => {
if (lastMessageId === null) return;
if (stickToBottom) {
requestAnimationFrame(scrollToBottom);
} else {
unseenBelowScroll = true;
}
if (lastMarkedRead !== lastMessageId) {
lastMarkedRead = lastMessageId;
markChatRoomRead({ chatRoomId, lastSeenMessageId: lastMessageId }).catch(
() => {
lastMarkedRead = null;
},
);
}
});
</script>
<section class={["container", className]}>
<div class="inputContainer">
<div
bind:this={messagesContainer}
class={["messages scrollbar", messagesContainerClass]}
role="log"
aria-label="Chat messages"
onscroll={handleScroll}
>
{#each snapshot?.messages ?? [] as message (message.id)}
{@const systemText = systemMessageText(message)}
{#if systemText}
<div class="message">
<div>
<div class="stack horizontal sm">
<time class="messageTime">
{formatTimestamp(message.createdAt)}
</time>
</div>
<div class="messageContents text-xs text-lighter font-semi-bold">
{systemText}
</div>
</div>
</div>
{:else if message.userId !== null && snapshot?.users[message.userId]}
{@const user = snapshot.users[message.userId]}
<div class="message">
<div class="avatarWrapper">
<Avatar {user} size="xs" />
</div>
<div>
<div class="messageInfo">
<div
class="messageUser"
style:--chat-hue={user.chatNameHue ?? undefined}
>
{user.username}
</div>
<time class="messageTime">
{formatTimestamp(message.createdAt)}
</time>
</div>
<div class="messageContents">
{message.contents}
</div>
</div>
</div>
{/if}
{/each}
</div>
{#if unseenBelowScroll}
<button type="button" class="unseenMessages" onclick={scrollToBottom}>
{m.common_chat_newMessages()}
</button>
{/if}
{#if snapshot && !canSend}
<div class="text-xs text-lighter text-center my-4">
{m.common_chat_expired()}
</div>
{:else}
<form onsubmit={handleSubmit}>
<input
class="w-full text-xs"
bind:value={inputValue}
placeholder={m.common_chat_input_placeholder()}
disabled={sendingDisabled}
maxlength={CHAT.MESSAGE_MAX_LENGTH}
/>
<div class="bottomRow">
{#if room.connected}
<div class="text-xxs font-semi-bold text-lighter">
{m.common_chat_connected()}
</div>
{:else if !room.done}
<div class="text-xxs font-semi-bold text-lighter">
{m.common_chat_connecting()}
</div>
{:else}
<div class="text-xxs font-semi-bold text-warning">
{m.common_chat_disconnected()}
</div>
{/if}
<Button
type="submit"
size="small"
class="sendButton"
disabled={sendingDisabled}
aria-label={m.common_chat_send()}
testId="chat-submit-button"
>
{#snippet icon()}<SendHorizontal size={16} />{/snippet}
</Button>
</div>
</form>
{/if}
</div>
</section>
<style>
.container {
display: flex;
flex-direction: column;
}
.messages {
padding: var(--s-3) 0 0 0;
display: block;
height: 310px;
overflow-x: hidden;
overflow-y: auto;
}
.message {
list-style: none;
display: flex;
gap: var(--s-3);
}
.messageInfo {
display: flex;
flex-direction: row;
gap: 0 var(--s-1-5);
align-items: center;
flex-wrap: wrap;
}
.avatarWrapper {
position: relative;
flex-shrink: 0;
}
.messageUser {
font-weight: var(--weight-semi);
font-size: var(--font-sm);
color: oklch(from var(--color-text-accent) l c var(--chat-hue));
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.messageTime {
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.inputContainer {
margin-top: auto;
position: relative;
& > form {
padding: var(--s-2);
border-top: 1.5px solid var(--color-border);
margin-block-start: var(--s-4);
}
}
.inputContainer :global(.sendButton) {
border-radius: var(--radius-full);
background-color: var(--color-text-accent);
color: var(--color-text-inverse);
flex-shrink: 0;
&:disabled {
opacity: 0.4;
}
}
.messageContents {
font-size: var(--font-sm);
word-break: break-word;
}
.bottomRow {
display: flex;
justify-content: space-between;
align-items: center;
margin-block-start: var(--s-2);
}
.unseenMessages {
position: absolute;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
border-radius: var(--radius-field);
background-color: var(--color-bg-higher);
border: none;
color: var(--color-text);
bottom: 60px;
right: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 25px;
width: max-content;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,397 @@
<script lang="ts">
import { ArrowLeft, MessageSquare, X } from "@lucide/svelte";
import { m } from "#lib/paraglide/messages.js";
import { getLocale } from "#lib/paraglide/runtime.js";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import { scrimPage } from "#lib/utils/urls.ts";
import { chatUi } from "../chat-state.svelte.ts";
import { getChatRooms } from "../chat.remote.ts";
import Chat from "./Chat.svelte";
interface Props {
onClose?: () => void;
}
let { onClose }: Props = $props();
const roomsQuery = getChatRooms();
const rooms = $derived(roomsQuery.current?.rooms ?? []);
const activeRooms = $derived(
rooms.filter((room) => room.lifecycle === "ACTIVE"),
);
const inactiveRooms = $derived(
rooms.filter((room) => room.lifecycle === "INACTIVE"),
);
const openRoom = $derived(
chatUi.openRoomId !== null
? (rooms.find((room) => room.id === chatUi.openRoomId) ?? null)
: null,
);
const totalUnseenElsewhere = $derived(
rooms
.filter((room) => room.id !== chatUi.openRoomId)
.reduce((total, room) => total + room.unseenCount, 0),
);
function roomTitle(_room: { scrimStartsAt: number | null }) {
// every room belongs to a scrim until other room types migrate
return m.common_chat_room_scrim();
}
function roomSubtitle(room: { scrimStartsAt: number | null }) {
if (room.scrimStartsAt === null) return "";
return new Intl.DateTimeFormat(getLocale(), {
weekday: "short",
hour: "numeric",
minute: "numeric",
}).format(databaseTimestampToDate(room.scrimStartsAt));
}
</script>
<div class="sidebar">
{#if openRoom}
<div class="chatHeader">
<button
type="button"
class="backButton"
aria-label={m.common_actions_back()}
onclick={() => {
chatUi.openRoomId = null;
}}
>
<ArrowLeft size={18} />
{#if totalUnseenElsewhere > 0}
<span class="backButtonBadge">{totalUnseenElsewhere}</span>
{/if}
</button>
<a
class="chatHeaderLink"
href={openRoom.scrimPostId !== null
? scrimPage(openRoom.scrimPostId)
: undefined}
>
<div class="chatHeaderInfo">
<div class="chatHeaderTitle">{roomTitle(openRoom)}</div>
<div class="chatHeaderSubtitle">{roomSubtitle(openRoom)}</div>
</div>
</a>
{#if onClose}
<button
type="button"
class="closeButton"
aria-label={m.common_actions_close()}
onclick={onClose}
>
<X size={18} />
</button>
{/if}
</div>
<div class="chatContainer">
{#key openRoom.id}
<Chat chatRoomId={openRoom.id} />
{/key}
</div>
{:else}
<div class="sidebarHeader">
<MessageSquare size={16} />
<h2>{m.common_chat_sidebar_title()}</h2>
{#if onClose}
<button
type="button"
class="closeButton"
aria-label={m.common_actions_close()}
onclick={onClose}
>
<X size={18} />
</button>
{/if}
</div>
<div class="roomList">
{#if rooms.length === 0}
<div class="emptyState">
{roomsQuery.current
? m.common_chat_sidebar_noActiveChats()
: m.common_chat_connecting()}
</div>
{:else}
{#each activeRooms as room (room.id)}
{@render roomItem(room)}
{/each}
{#if inactiveRooms.length > 0}
<div class="inactiveDivider">
{m.common_chat_sidebar_inactive()}
</div>
{#each inactiveRooms as room (room.id)}
{@render roomItem(room)}
{/each}
{/if}
{/if}
</div>
{/if}
</div>
{#snippet roomItem(room: (typeof rooms)[number])}
<button
type="button"
class="roomItem"
onclick={() => {
chatUi.openRoomId = room.id;
}}
>
<div class="roomItemTexts">
<div class="roomName">{roomTitle(room)}</div>
<div class="roomTimestamp">{roomSubtitle(room)}</div>
</div>
{#if room.unseenCount > 0}
<span class="unreadBadge">{room.unseenCount}</span>
{/if}
</button>
{/snippet}
<style>
.sidebar {
display: flex;
flex-direction: column;
height: 100%;
max-height: var(--visual-viewport-height, 100dvh);
overflow: hidden;
}
.sidebarHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-4);
background-color: var(--color-bg-high);
border-bottom: 1.5px solid var(--color-border);
flex-shrink: 0;
color: var(--color-text-high);
min-height: var(--layout-nav-height);
& h2 {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
margin: 0;
}
}
.closeButton {
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-error);
height: var(--field-size);
aspect-ratio: 1 / 1;
padding: 0;
border-radius: var(--radius-field);
margin-inline-start: auto;
@media screen and (min-width: 600px) {
height: var(--field-size-sm);
}
&:hover {
background-color: var(--color-bg-higher);
}
}
.roomList {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
padding: var(--s-1-5);
overflow-y: auto;
flex: 1;
min-height: 0;
}
.roomItem {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2);
background: none;
border: none;
cursor: pointer;
color: inherit;
text-align: start;
border-radius: var(--radius-field);
&:hover {
background-color: var(--color-bg-higher);
}
}
.roomItemTexts {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.roomName {
font-weight: var(--weight-semi);
font-size: var(--font-xs);
}
.roomTimestamp {
font-size: var(--font-2xs);
color: var(--color-text-high);
flex-shrink: 0;
}
.inactiveDivider {
padding: var(--s-1-5) var(--s-2);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-high);
}
.unreadBadge {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 18px;
height: 18px;
padding: 0 var(--s-1);
border-radius: 9px;
display: grid;
place-items: center;
flex-shrink: 0;
}
.chatHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-2);
border-bottom: 1.5px solid var(--color-border);
background-color: var(--color-bg-high);
flex-shrink: 0;
min-height: var(--layout-nav-height);
}
.backButton {
position: relative;
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-text);
height: var(--field-size-sm);
aspect-ratio: 1 / 1;
border-radius: var(--radius-field);
&:hover {
background-color: var(--color-bg-higher);
}
}
.backButtonBadge {
position: absolute;
top: -2px;
right: -2px;
font-size: var(--font-3xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 7px;
display: grid;
place-items: center;
line-height: 1;
}
.chatHeaderInfo {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.chatHeaderLink {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1-5);
overflow: hidden;
min-width: 0;
text-decoration: none;
color: inherit;
border-radius: var(--radius-field);
transition: background-color 0.15s;
&:is(a):hover {
background-color: var(--color-bg-higher);
}
}
.chatHeaderTitle {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chatHeaderSubtitle {
font-size: var(--font-2xs);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
padding: var(--s-4);
text-align: center;
color: var(--color-text-high);
font-size: var(--font-xs);
}
.chatContainer {
flex: 1;
min-height: 0;
overflow: hidden;
padding-block-start: var(--s-2);
display: flex;
flex-direction: column;
& > :global(section) {
height: 100%;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
& :global(section > div) {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 0;
min-height: 0;
}
& :global([role="log"]) {
padding-top: 0;
flex: 1;
height: auto;
}
& :global(form) {
flex-shrink: 0;
}
}
</style>

View File

@@ -151,3 +151,112 @@ export async function findFriendIds(userId: number): Promise<number[]> {
return rows.map((row) => row.friendId);
}
/**
* The pending friend request between the two users regardless of direction, or `undefined` when
* there is none.
*/
export async function findFriendRequestBetween({
senderId,
receiverId,
}: {
senderId: number;
receiverId: number;
}) {
return db
.selectFrom("FriendRequest")
.select(["FriendRequest.id", "FriendRequest.senderId"])
.where((eb) =>
eb.or([
eb.and([
eb("FriendRequest.senderId", "=", senderId),
eb("FriendRequest.receiverId", "=", receiverId),
]),
eb.and([
eb("FriendRequest.senderId", "=", receiverId),
eb("FriendRequest.receiverId", "=", senderId),
]),
]),
)
.executeTakeFirst();
}
/** Users who are friends with both given users. */
export async function findMutualFriends({
loggedInUserId,
targetUserId,
}: {
loggedInUserId: number;
targetUserId: number;
}) {
return db
.selectFrom("Friendship as f1")
.innerJoin("Friendship as f2", (join) =>
join.on((eb) =>
eb.and([
eb(
eb
.case()
.when("f1.userOneId", "=", loggedInUserId)
.then(eb.ref("f1.userTwoId"))
.else(eb.ref("f1.userOneId"))
.end(),
"=",
eb
.case()
.when("f2.userOneId", "=", targetUserId)
.then(eb.ref("f2.userTwoId"))
.else(eb.ref("f2.userOneId"))
.end(),
),
]),
),
)
.innerJoin("User", (join) =>
join.on((eb) =>
eb(
"User.id",
"=",
eb
.case()
.when("f1.userOneId", "=", loggedInUserId)
.then(eb.ref("f1.userTwoId"))
.else(eb.ref("f1.userOneId"))
.end(),
),
),
)
.where((eb) =>
eb.or([
eb("f1.userOneId", "=", loggedInUserId),
eb("f1.userTwoId", "=", loggedInUserId),
]),
)
.where((eb) =>
eb.or([
eb("f2.userOneId", "=", targetUserId),
eb("f2.userTwoId", "=", targetUserId),
]),
)
.select((eb) => commonUserSelect(eb))
.execute();
}
/** The friendship row between the two users, or `undefined` when they are not friends. */
export async function findFriendship({
userOneId,
userTwoId,
}: {
userOneId: number;
userTwoId: number;
}) {
const minId = Math.min(userOneId, userTwoId);
const maxId = Math.max(userOneId, userTwoId);
return db
.selectFrom("Friendship")
.select("Friendship.id")
.where("Friendship.userOneId", "=", minId)
.where("Friendship.userTwoId", "=", maxId)
.executeTakeFirst();
}

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { m } from "#lib/paraglide/messages.js";
interface Props {
associations: {
virtual: string[];
actual: Array<{ id: number; name: string }>;
};
id: string;
value: string;
onChange: (value: string) => void;
}
let { associations, id, value, onChange }: Props = $props();
</script>
<select
{id}
class="w-full"
{value}
onchange={(event) => onChange(event.currentTarget.value)}
>
<option value="PUBLIC">{m.scrims_forms_visibility_public()}</option>
{#each associations.virtual as association (association)}
<option value={association}>
{association === "FRIENDS"
? m.scrims_forms_visibility_friends()
: association}
</option>
{/each}
{#each associations.actual as association (association.id)}
<option value={association.id}>{association.name}</option>
{/each}
</select>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import FormMessage from "#lib/components/FormMessage.svelte";
import FormFieldWrapper from "#lib/form/fields/FormFieldWrapper.svelte";
import { m } from "#lib/paraglide/messages.js";
import AssociationSelect from "./AssociationSelect.svelte";
interface Props {
associations: {
virtual: string[];
actual: Array<{ id: number; name: string }>;
};
name: string;
value: unknown;
onChange: (value: unknown) => void;
error: string | undefined;
}
let { associations, name, value, onChange, error }: Props = $props();
const id = $props.id();
const noAssociations = $derived(
associations.virtual.length === 0 && associations.actual.length === 0,
);
</script>
<FormFieldWrapper
{id}
{name}
label={m.scrims_forms_visibility_title()}
{error}
>
{#if noAssociations}
<FormMessage type="info">
{m.scrims_forms_visibility_noneAvailable()}
</FormMessage>
{:else}
<AssociationSelect
{associations}
{id}
value={String(value)}
{onChange}
/>
{/if}
</FormFieldWrapper>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import DualSelectFormField from "#lib/form/fields/DualSelectFormField.svelte";
import { m } from "#lib/paraglide/messages.js";
import { LUTI_DIVS } from "../scrims-constants.ts";
import type { LutiDiv, ScrimFilters } from "../scrims-types.ts";
interface Props {
value: ScrimFilters["divs"];
onChange: (value: ScrimFilters["divs"]) => void;
}
let { value, onChange }: Props = $props();
// svelte-ignore state_referenced_locally -- the filter value seeds the draft only
let draft = $state<[LutiDiv | null, LutiDiv | null]>([
value?.max ?? null,
value?.min ?? null,
]);
const divItems = LUTI_DIVS.map((div) => ({ label: div, value: div }));
function handleChange(newValue: [string | null, string | null]) {
const typedValue = newValue as [LutiDiv | null, LutiDiv | null];
draft = typedValue;
const [max, min] = typedValue;
if (max !== null && min !== null) {
onChange({ max, min });
} else if (max === null && min === null) {
onChange(null);
}
}
</script>
<DualSelectFormField
name="divs"
fields={[
{ label: m.forms_labels_scrimMaxDiv(), items: divItems },
{ label: m.forms_labels_scrimMinDiv(), items: divItems },
]}
value={draft}
onChange={handleChange}
/>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import FormField from "#lib/form/FormField.svelte";
import { getFormContext } from "#lib/form/form-context.ts";
const context = getFormContext();
const showField = $derived(context.value("maps") === "TOURNAMENT");
</script>
{#if showField}
<FormField name="mapsTournamentId" />
{/if}

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import * as v from "valibot";
import FormField from "#lib/form/FormField.svelte";
import SendouForm from "#lib/form/SendouForm.svelte";
import { m } from "#lib/paraglide/messages.js";
import { scrimsPage } from "#lib/utils/urls.ts";
import { goto } from "$app/navigation";
import { SCRIM } from "../scrims-constants.ts";
import { createScrimPost, getScrimsNewData } from "../scrims.remote.ts";
import { scrimsNewFormSchema } from "../scrims-schemas.ts";
import BaseVisibilityFormField from "./BaseVisibilityFormField.svelte";
import MapsTournamentFormField from "./MapsTournamentFormField.svelte";
import NotFoundVisibilityFormField, {
DEFAULT_NOT_FOUND_VISIBILITY,
} from "./NotFoundVisibilityFormField.svelte";
import WithFormField from "./WithFormField.svelte";
const data = $derived(await getScrimsNewData());
function nullFilledArray(length: number) {
return Array.from({ length }, () => null);
}
async function handleSubmit(values: Record<string, unknown>) {
const result = await createScrimPost(
values as v.InferInput<typeof scrimsNewFormSchema>,
);
if (result?.fieldErrors) return result;
await goto(scrimsPage());
return undefined;
}
</script>
<SendouForm
schema={scrimsNewFormSchema}
title={m.scrims_forms_title()}
defaultValues={{
postText: "",
at: new Date(),
rangeEnd: null,
baseVisibility: "PUBLIC",
notFoundVisibility: DEFAULT_NOT_FOUND_VISIBILITY,
from:
data.teams.length > 0
? { mode: "TEAM", teamId: data.teams[0].id }
: {
mode: "PICKUP",
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
) as unknown as number[],
},
managedByAnyone: true,
maps: "NO_PREFERENCE",
mapsTournamentId: null,
}}
onSubmit={handleSubmit}
>
<FormField name="from">
{#snippet children(props)}
<WithFormField
usersTeams={data.teams}
recentPickupRosters={data.recentPickupRosters}
{...props}
/>
{/snippet}
</FormField>
<FormField name="at" />
<FormField name="rangeEnd" />
<FormField name="baseVisibility">
{#snippet children(props)}
<BaseVisibilityFormField associations={data.associations} {...props} />
{/snippet}
</FormField>
<NotFoundVisibilityFormField associations={data.associations} />
<FormField name="divs" />
<FormField name="maps" />
<MapsTournamentFormField />
<FormField name="postText" />
<FormField name="managedByAnyone" />
</SendouForm>

View File

@@ -0,0 +1,108 @@
<script lang="ts" module>
export interface NotFoundVisibilityValue {
at: Date | null;
forAssociation: string;
}
export const DEFAULT_NOT_FOUND_VISIBILITY: NotFoundVisibilityValue = {
at: null,
forAssociation: "PUBLIC",
};
</script>
<script lang="ts">
import DatePicker from "#lib/components/DatePicker.svelte";
import Label from "#lib/components/Label.svelte";
import { getFormContext } from "#lib/form/form-context.ts";
import { errorMessageId, translateFormText } from "#lib/form/form-utils.ts";
import { m } from "#lib/paraglide/messages.js";
import AssociationSelect from "./AssociationSelect.svelte";
interface Props {
associations: {
virtual: string[];
actual: Array<{ id: number; name: string }>;
};
}
let { associations }: Props = $props();
const context = getFormContext();
const baseVisibility = $derived(context.value("baseVisibility") as string);
const notFoundVisibility = $derived(
context.value("notFoundVisibility") as NotFoundVisibilityValue,
);
// a public post can't have a delayed visibility expansion, so flipping back to
// PUBLIC clears the whole sub-field
$effect(() => {
if (baseVisibility !== "PUBLIC") return;
if (
notFoundVisibility.at === null &&
notFoundVisibility.forAssociation === "PUBLIC"
) {
return;
}
context.setValue("notFoundVisibility", DEFAULT_NOT_FOUND_VISIBILITY);
});
const error = $derived(context.displayedError("notFoundVisibility"));
const noAssociations = $derived(
associations.virtual.length === 0 && associations.actual.length === 0,
);
function handleDateChange(date: Date | null) {
context.setValue("notFoundVisibility", {
...notFoundVisibility,
at: date,
});
}
function handleAssociationChange(value: string) {
context.setValue("notFoundVisibility", {
...notFoundVisibility,
forAssociation: value,
});
}
</script>
{#if !noAssociations && baseVisibility !== "PUBLIC"}
<div>
<div class="stack horizontal sm">
<div class="datePickerFullWidth">
<DatePicker
label={m.scrims_forms_notFoundVisibility_title()}
granularity="minute"
errorText={error ? translateFormText(error) : undefined}
errorId={errorMessageId("notFoundVisibility")}
value={notFoundVisibility.at}
onChange={handleDateChange}
bottomText={notFoundVisibility.at
? undefined
: m.scrims_forms_notFoundVisibility_explanation()}
/>
</div>
{#if notFoundVisibility.at}
<div class="w-full">
<Label htmlFor="not-found-visibility">
{m.scrims_forms_visibility_title()}
</Label>
<AssociationSelect
{associations}
id="not-found-visibility"
value={String(notFoundVisibility.forAssociation)}
onChange={handleAssociationChange}
/>
</div>
{/if}
</div>
</div>
{/if}
<style>
.datePickerFullWidth {
width: 100%;
}
</style>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { Dialog } from "@sendou/components";
import * as v from "valibot";
import FormField from "#lib/form/FormField.svelte";
import SendouForm from "#lib/form/SendouForm.svelte";
import { pickMap } from "../scrims.remote.ts";
import { pickMapFormSchema } from "../scrims-schemas.ts";
interface Props {
trigger: Snippet<[{ onclick: () => void }]>;
heading: string;
scrimPostId: number;
}
let { trigger, heading, scrimPostId }: Props = $props();
async function handleSubmit(values: Record<string, unknown>) {
await pickMap(values as v.InferInput<typeof pickMapFormSchema>);
return undefined;
}
</script>
<Dialog {heading} {trigger}>
<SendouForm
schema={pickMapFormSchema}
defaultValues={{ scrimPostId, mode: "SZ" }}
onSubmit={handleSubmit}
>
<FormField name="mode" />
<FormField name="stageId" />
</SendouForm>
</Dialog>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { m } from "#lib/paraglide/messages.js";
/**
* The card chrome shared by `ScrimPostCard` and `ScrimRequestCard`; owns the
* styles the React cards shared through one CSS module.
*/
interface Props {
isRequestCard?: boolean;
isPickup: boolean;
teamName: string;
ownerUsername: string;
avatar: Snippet;
rightIcons?: Snippet;
children?: Snippet;
footerClass?: "requestFooter" | "filteredFooter";
footer?: Snippet;
showFooter?: boolean;
}
let {
isRequestCard = false,
isPickup,
teamName,
ownerUsername,
avatar,
rightIcons,
children,
footerClass,
footer,
showFooter = true,
}: Props = $props();
</script>
<div class={["card", { requestCard: isRequestCard }]}>
<div class="header">
<div class="avatarContainer">
{@render avatar()}
</div>
<h3 class="teamName">
{#if isPickup}
<span class="pickupLabel">{m.scrims_pickupBy()}</span>
<span>{ownerUsername}</span>
{:else}
{teamName}
{/if}
</h3>
{#if rightIcons}
<div class="rightIconsContainer">
{@render rightIcons()}
</div>
{/if}
</div>
{#if children}{@render children()}{/if}
{#if footer && showFooter}
<div
class={[
"footer",
{
requestFooter: footerClass === "requestFooter",
filteredFooter: footerClass === "filteredFooter",
},
]}
>
{@render footer()}
</div>
{/if}
</div>
<style>
.card {
border: var(--border-style);
border-radius: var(--radius-box);
overflow: hidden;
background-color: var(--color-bg);
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-4);
padding-bottom: var(--s-3);
}
.avatarContainer {
flex-shrink: 0;
}
.teamName {
flex: 1;
font-size: var(--font-lg);
font-weight: var(--weight-semi);
margin: 0;
display: flex;
flex-direction: column;
gap: var(--s-0-5);
line-height: 1.2;
}
.pickupLabel {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
color: var(--color-text-high);
}
.rightIconsContainer {
flex-shrink: 0;
align-self: flex-start;
display: flex;
gap: var(--s-2);
}
.footer {
background-color: var(--color-bg-high);
padding: var(--s-2) var(--s-4);
display: flex;
justify-content: center;
margin-block-start: auto;
}
.requestCard {
background-color: var(--color-bg-high);
}
.requestFooter {
background-image: repeating-linear-gradient(
45deg,
var(--color-bg),
var(--color-bg) 10px,
var(--color-bg-higher) 10px,
var(--color-bg-higher) 20px
);
}
.filteredFooter {
background-color: var(--color-accent-low);
}
</style>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { m } from "#lib/paraglide/messages.js";
interface Props {
text: string;
}
let { text }: Props = $props();
let isExpanded = $state(false);
let isOverflowing = $state(false);
function measure(node: HTMLDivElement) {
if (node.scrollHeight - node.clientHeight > 1) {
isOverflowing = true;
}
}
</script>
<div class="textContent">
<div class={{ clampedText: !isExpanded }} {@attach measure}>
{text}
</div>
{#if isOverflowing}
<button
type="button"
onclick={() => {
isExpanded = !isExpanded;
}}
class="expandButton"
>
{isExpanded ? m.common_actions_showLess() : m.common_actions_showMore()}
</button>
{/if}
</div>
<style>
.textContent {
padding-inline: var(--s-4);
padding-bottom: var(--s-3);
font-size: var(--font-sm);
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.clampedText {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.expandButton {
background: none;
border: none;
color: var(--color-text-accent);
cursor: pointer;
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
padding: 0;
text-align: left;
text-decoration: underline;
&:hover {
opacity: 0.8;
}
}
</style>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import * as v from "valibot";
import FormField from "#lib/form/FormField.svelte";
import SendouForm from "#lib/form/SendouForm.svelte";
import { m } from "#lib/paraglide/messages.js";
import { type ScrimPageData, submitMapList } from "../scrims.remote.ts";
import { submitMapListFormSchema } from "../scrims-schemas.ts";
import SourceDependentFields from "./SourceDependentFields.svelte";
type SourceValue = "POOL" | "TOURNAMENT" | "FROM_POST";
interface Props {
data: ScrimPageData;
}
let { data }: Props = $props();
const postTournament = $derived(data.post.mapsTournament);
const isPostAuthorSide = $derived(data.mapByMap.viewerSide === "ALPHA");
const useFromPost = $derived(postTournament != null && isPostAuthorSide);
// svelte-ignore state_referenced_locally -- the form's default source is decided once at mount
const defaultSource: SourceValue = useFromPost ? "FROM_POST" : "TOURNAMENT";
const sourceItems = $derived(
useFromPost && postTournament
? [
{ value: "FROM_POST", label: () => postTournament.name },
{ value: "POOL", label: () => m.forms_options_scrimMapSource_POOL() },
]
: [
{
value: "TOURNAMENT",
label: () => m.forms_options_scrimMapSource_TOURNAMENT(),
},
{ value: "POOL", label: () => m.forms_options_scrimMapSource_POOL() },
],
);
async function handleSubmit(values: Record<string, unknown>) {
await submitMapList(
values as v.InferInput<typeof submitMapListFormSchema>,
);
return undefined;
}
</script>
<div data-testid="scrim-map-list-form">
<SendouForm
title={m.scrims_mapByMap_submitListHeading()}
schema={submitMapListFormSchema}
submitButtonTestId="submit-map-list-button"
fullWidth
defaultValues={{ scrimPostId: data.post.id, source: defaultSource }}
onSubmit={handleSubmit}
>
<FormField name="source" options={sourceItems} />
<SourceDependentFields />
</SendouForm>
</div>

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { Map as MapIcon, Trash2 } from "@lucide/svelte";
import { Button } from "@sendou/components";
import ConfirmDialog from "#lib/components/ConfirmDialog.svelte";
import SecondaryAction from "#lib/components/match-page/SecondaryAction.svelte";
import { m } from "#lib/paraglide/messages.js";
import { removeMapList, type ScrimPageData } from "../scrims.remote.ts";
import type { ScrimSide } from "../scrims-types.ts";
import ScrimMapListForm from "./ScrimMapListForm.svelte";
interface Props {
data: ScrimPageData;
viewerSide: ScrimSide;
standalone?: boolean;
}
let { data, viewerSide, standalone }: Props = $props();
const ownList = $derived(
data.mapByMap.mapLists.find((list) => list.side === viewerSide),
);
// svelte-ignore state_referenced_locally -- whether the manager starts open is decided once at mount
let isOpen = $state(!ownList);
const sides: ScrimSide[] = ["ALPHA", "BRAVO"];
function sideLabel(side: ScrimSide) {
return side === "ALPHA" ? m.q_match_sides_alpha() : m.q_match_sides_bravo();
}
</script>
<SecondaryAction
{isOpen}
onOpenChange={(open) => {
isOpen = open;
}}
collapsedLabel={m.scrims_mapByMap_manageMapLists()}
{standalone}
>
{#snippet collapsedIcon()}<MapIcon size={16} />{/snippet}
<div class="root">
{#if !ownList}
<ScrimMapListForm {data} />
{/if}
<div class="mapListsSummary">
{#each sides as side (side)}
{@const list = data.mapByMap.mapLists.find(
(candidate) => candidate.side === side,
)}
{@const isOwn = side === viewerSide}
<div class="mapListRow" data-testid={`map-list-row-${side}`}>
<div class="mapListRowHeader">{sideLabel(side)}</div>
<div class="mapListBody">
{#if list}
{#if list.tournament}
<span>{list.tournament.name}</span>
{:else}
<span>
{m.scrims_mapByMap_poolList({ count: list.mapList.length })}
</span>
{/if}
{#if isOwn}
<ConfirmDialog
dialogHeading={m.scrims_mapByMap_removeListConfirm()}
submitButtonText={m.common_actions_remove()}
onConfirm={() =>
removeMapList({ scrimPostId: data.post.id })}
>
{#snippet trigger(triggerProps)}
<Button
variant="minimal-destructive"
size="miniscule"
aria-label={m.scrims_mapByMap_removeList()}
{...triggerProps}
>
{#snippet icon()}<Trash2 size={16} />{/snippet}
</Button>
{/snippet}
</ConfirmDialog>
{/if}
{:else}
<span class="mapListRowMissing">
{m.scrims_mapByMap_noListYet()}
</span>
{/if}
</div>
</div>
{/each}
</div>
</div>
</SecondaryAction>
<style>
.root {
display: flex;
flex-direction: column;
gap: var(--s-4);
align-items: stretch;
width: 100%;
container-type: inline-size;
}
.mapListsSummary {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.mapListRow {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-2);
}
.mapListRowHeader {
font-weight: var(--weight-bold);
}
.mapListBody {
display: flex;
align-items: center;
gap: var(--s-2);
}
.mapListRowMissing {
font-style: italic;
color: var(--color-text-high);
}
</style>

View File

@@ -0,0 +1,167 @@
<script lang="ts">
import { MapPin, Repeat, Undo2 } from "@lucide/svelte";
import { Button, TabPanel } from "@sendou/components";
import MatchActionTab from "#lib/components/match-page/MatchActionTab.svelte";
import { TAB_KEYS } from "#lib/components/match-page/match-page-constants.ts";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { m } from "#lib/paraglide/messages.js";
import * as Scrim from "../Scrim.ts";
import * as ScrimMapByMap from "../ScrimMapByMap.ts";
import { replayMap, reportMap, type ScrimPageData, undoMap } from "../scrims.remote.ts";
import PickMapDialog from "./PickMapDialog.svelte";
import ScrimMapListManager from "./ScrimMapListManager.svelte";
const ALPHA_TEAM_ID = 1;
const BRAVO_TEAM_ID = 2;
interface Props {
data: ScrimPageData;
}
let { data }: Props = $props();
const user = $derived(loggedInUser());
const viewerSide = $derived(
user ? Scrim.sideOfUser(data.post, user.id) : null,
);
const currentMap = $derived(data.mapByMap.currentMap);
const acceptedRequest = $derived(
data.post.requests.find((r) => r.isAccepted),
);
const alphaName = $derived(
data.post.team ? Scrim.sideDisplayName(data.post) : m.q_match_groupAlpha(),
);
const bravoName = $derived(
acceptedRequest?.team
? Scrim.sideDisplayName(acceptedRequest)
: m.q_match_groupBravo(),
);
let reportPending = $state(false);
let undoPending = $state(false);
let replayPending = $state(false);
const maps = $derived(data.mapByMap.maps);
const latest = $derived(Scrim.lastReportedMap(maps));
const undoAllowed = $derived(ScrimMapByMap.canUndo(latest, maps));
const replayAllowed = $derived(Boolean(latest && currentMap));
async function handleReport(submitted: { winnerId: number }) {
if (!currentMap) return;
reportPending = true;
try {
await reportMap({
scrimPostId: data.post.id,
mapId: currentMap.id,
winnerSide: submitted.winnerId === ALPHA_TEAM_ID ? "ALPHA" : "BRAVO",
});
} finally {
reportPending = false;
}
}
async function handleUndo() {
undoPending = true;
try {
await undoMap({ scrimPostId: data.post.id });
} finally {
undoPending = false;
}
}
async function handleReplay() {
replayPending = true;
try {
await replayMap({ scrimPostId: data.post.id });
} finally {
replayPending = false;
}
}
</script>
{#if !data.mapByMap.locked}
{#if !viewerSide}
<TabPanel id={TAB_KEYS.ACTION}>
<div class="locked">
{m.scrims_mapByMap_nonParticipantNotice()}
</div>
</TabPanel>
{:else if !currentMap}
<TabPanel id={TAB_KEYS.ACTION}>
<ScrimMapListManager {data} {viewerSide} standalone />
</TabPanel>
{:else}
{#key currentMap.id}
<MatchActionTab
teams={[
{ id: ALPHA_TEAM_ID, name: alphaName },
{ id: BRAVO_TEAM_ID, name: bravoName },
]}
ownTeamId={viewerSide === "ALPHA" ? ALPHA_TEAM_ID : BRAVO_TEAM_ID}
stageId={currentMap.stageId}
mode={currentMap.mode}
withKo={false}
isSubmitting={reportPending}
onSubmit={handleReport}
>
{#snippet actionButtons()}
<Button
testId="undo-map-button"
variant="minimal-destructive"
size="miniscule"
disabled={!undoAllowed || undoPending}
onclick={handleUndo}
>
{#snippet icon()}<Undo2 size={16} />{/snippet}
{m.scrims_mapByMap_undo()}
</Button>
<Button
testId="replay-map-button"
variant="minimal"
size="miniscule"
disabled={!replayAllowed || replayPending}
onclick={handleReplay}
>
{#snippet icon()}<Repeat size={16} />{/snippet}
{m.scrims_mapByMap_replay()}
</Button>
{#key `${currentMap.id}-${currentMap.mode}-${currentMap.stageId}`}
<PickMapDialog
heading={m.scrims_mapByMap_pickDialog_heading()}
scrimPostId={data.post.id}
>
{#snippet trigger(triggerProps)}
<Button
testId="pick-map-button"
variant="minimal"
size="miniscule"
{...triggerProps}
>
{#snippet icon()}<MapPin size={16} />{/snippet}
{m.scrims_mapByMap_pick()}
</Button>
{/snippet}
</PickMapDialog>
{/key}
{/snippet}
{#snippet secondaryAction()}
<ScrimMapListManager {data} {viewerSide} />
{/snippet}
</MatchActionTab>
{/key}
{/if}
{/if}
<style>
.locked {
padding: var(--s-3);
border-radius: var(--radius-box);
background: var(--color-bg-high);
color: var(--color-text-high);
text-align: center;
}
</style>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { Ban, Swords } from "@lucide/svelte";
import IconBanner from "#lib/components/match-page/IconBanner.svelte";
import MatchBanner from "#lib/components/match-page/MatchBanner.svelte";
import MatchBannerContainer from "#lib/components/match-page/MatchBannerContainer.svelte";
import MatchBannerScheduledTime from "#lib/components/match-page/MatchBannerScheduledTime.svelte";
import MatchBannerTopRow from "#lib/components/match-page/MatchBannerTopRow.svelte";
import { resolveRoomPass } from "#lib/components/match-page/match-page-utils.ts";
import { m } from "#lib/paraglide/messages.js";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import * as Scrim from "../Scrim.ts";
import type { ScrimPageData } from "../scrims.remote.ts";
interface Props {
data: ScrimPageData;
}
let { data }: Props = $props();
const screenLegal = $derived(!data.anyUserPrefersNoScreen);
const acceptedRequest = $derived(
data.post.requests.find((r) => r.isAccepted),
);
const scheduledAt = $derived(
databaseTimestampToDate(acceptedRequest?.startsAt ?? data.post.startsAt),
);
const joinPool = $derived(Scrim.resolvePoolCode(data.post.id));
const joinPass = $derived(resolveRoomPass(data.post.id));
const currentMap = $derived(data.mapByMap.currentMap);
</script>
{#snippet topRow()}
<MatchBannerTopRow>
<MatchBannerScheduledTime time={scheduledAt} />
</MatchBannerTopRow>
{/snippet}
{#if data.post.canceled}
<MatchBannerContainer>
{@render topRow()}
<IconBanner
header={m.scrims_banner_canceled_header({
user: data.post.canceled.byUser.username,
})}
subtitle={m.scrims_banner_canceled_subtitle({
reason: data.post.canceled.reason,
})}
>
{#snippet icon()}<Ban size={32} />{/snippet}
</IconBanner>
</MatchBannerContainer>
{:else if currentMap}
<MatchBannerContainer>
{@render topRow()}
<MatchBanner
stageId={currentMap.stageId}
mode={currentMap.mode}
{screenLegal}
{joinPool}
{joinPass}
/>
</MatchBannerContainer>
{:else}
<MatchBannerContainer>
{@render topRow()}
<IconBanner
header={m.scrims_banner_freeForm_header()}
subtitle={m.scrims_banner_freeForm_subtitle()}
{screenLegal}
{joinPool}
{joinPass}
>
{#snippet icon()}<Swords size={32} />{/snippet}
</IconBanner>
</MatchBannerContainer>
{/if}

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { Button, Dialog } from "@sendou/components";
import MatchPageHeader from "#lib/components/match-page/MatchPageHeader.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import FormField from "#lib/form/FormField.svelte";
import SendouForm from "#lib/form/SendouForm.svelte";
import { hasPermission } from "#lib/modules/permissions/utils.ts";
import { m } from "#lib/paraglide/messages.js";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import * as Scrim from "../Scrim.ts";
import { cancelScrim, type ScrimPageData } from "../scrims.remote.ts";
import { cancelScrimFormSchema } from "../scrims-schemas.ts";
interface Props {
data: ScrimPageData;
}
let { data }: Props = $props();
const user = $derived(loggedInUser());
const allowedToCancel = $derived(hasPermission(data.post, "CANCEL", user));
const isCanceled = $derived(Boolean(data.post.canceled));
const canCancel = $derived(
allowedToCancel &&
!isCanceled &&
databaseTimestampToDate(data.post.startsAt) > new Date(),
);
const acceptedRequest = $derived(
data.post.requests.find((r) => r.isAccepted),
);
const opponentSide = $derived.by(() => {
const viewerSide = data.mapByMap.viewerSide;
if (viewerSide === "ALPHA") return acceptedRequest;
if (viewerSide === "BRAVO") return data.post;
return acceptedRequest;
});
async function handleCancelSubmit(values: Record<string, unknown>) {
await cancelScrim(
values as { scrimPostId: number; reason: string },
);
return undefined;
}
</script>
<MatchPageHeader subtitle={m.scrims_page_scheduledScrim()}>
{#snippet topRight()}
{#if canCancel}
<Dialog heading={m.scrims_cancelModal_scrim_title()} showCloseButton>
{#snippet trigger(triggerProps)}
<Button size="small" variant="minimal-destructive" {...triggerProps}>
{m.common_actions_cancel()}
</Button>
{/snippet}
<SendouForm
schema={cancelScrimFormSchema}
submitButtonTestId="cancel-scrim-submit"
defaultValues={{ scrimPostId: data.post.id, reason: "" }}
onSubmit={handleCancelSubmit}
>
<FormField name="reason" />
</SendouForm>
</Dialog>
{/if}
{/snippet}
{#if opponentSide}
{m.scrims_page_vs({ opponent: Scrim.sideDisplayName(opponentSide) })}
{/if}
</MatchPageHeader>

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import type { ModeShort, StageId } from "@sendou/in-game-lists/types";
import { ChipRadio, ChipRadioGroup, Switch, TabPanel } from "@sendou/components";
import { MapPool } from "@sendou/map-list-generator/map-pool";
import ModeImage from "#lib/components/ModeImage.svelte";
import StageImage from "#lib/components/StageImage.svelte";
import Table from "#lib/components/Table.svelte";
import { TAB_KEYS } from "#lib/components/match-page/match-page-constants.ts";
import { modeLongName, stageName } from "#lib/modules/i18n/messages.ts";
import { dynamicMessage } from "#lib/modules/i18n/messages.ts";
import { m } from "#lib/paraglide/messages.js";
import * as ScrimMapByMap from "../ScrimMapByMap.ts";
import type { ScrimPageData } from "../scrims.remote.ts";
type View = "MODE" | "STAGE" | "BOTH";
const VIEW_OPTIONS: View[] = ["MODE", "STAGE", "BOTH"];
interface Props {
data: ScrimPageData;
}
let { data }: Props = $props();
const viewerSide = $derived(data.mapByMap.viewerSide);
const maps = $derived(data.mapByMap.maps);
const ownPool = $derived(
data.mapByMap.ownPool ? new MapPool(data.mapByMap.ownPool) : null,
);
let view = $state<View>("BOTH");
// svelte-ignore state_referenced_locally -- whether a pool exists seeds the initial toggle only
let restrictToPool = $state(Boolean(ownPool));
const stats = $derived(
viewerSide
? ScrimMapByMap.stats(maps, viewerSide, {
restrictToPool: restrictToPool && ownPool ? ownPool : undefined,
})
: null,
);
interface StatsRow {
key: string;
wins: number;
losses: number;
winRate: number;
}
function sortRows(rows: Array<{ key: string; wins: number; losses: number }>) {
return rows
.map((row) => ({
...row,
winRate: row.wins / (row.wins + row.losses),
}))
.sort((a, b) => {
if (b.winRate !== a.winRate) return b.winRate - a.winRate;
return b.wins + b.losses - (a.wins + a.losses);
});
}
function viewLabel(option: View) {
return dynamicMessage(`scrims_mapByMap_stats_view_${option}`);
}
</script>
<TabPanel id={TAB_KEYS.STATS}>
{#if !viewerSide || maps.length === 0}
<div class="empty">{m.scrims_mapByMap_stats_empty()}</div>
{:else if stats}
<div class="root" data-testid="scrim-stats-root">
<div class="controls">
<ChipRadioGroup>
{#each VIEW_OPTIONS as option (option)}
<ChipRadio
name="scrim-stats-view"
value={option}
checked={view === option}
onChange={(value) => {
view = value as View;
}}
>
{viewLabel(option)}
</ChipRadio>
{/each}
</ChipRadioGroup>
{#if ownPool}
<label class="toggleRow">
<Switch
isSelected={restrictToPool}
onChange={(isSelected) => {
restrictToPool = isSelected;
}}
/>
{m.scrims_mapByMap_stats_restrictToPool()}
</label>
{/if}
</div>
{#if view === "MODE"}
{@render statsTable(sortRows(stats.byMode), modeLabel)}
{:else if view === "STAGE"}
{@render statsTable(sortRows(stats.byStage), stageLabel)}
{:else}
{@render statsTable(sortRows(stats.byStageMode), stageModeLabel)}
{/if}
</div>
{/if}
</TabPanel>
{#snippet modeLabel(key: string)}
<span class="stageModeLabel">
<ModeImage mode={key as ModeShort} size={20} />
{modeLongName(key)}
</span>
{/snippet}
{#snippet stageLabel(key: string)}
<span class="stageModeLabel">
<StageImage
stageId={Number(key) as StageId}
width={36}
class="stageImage"
/>
{stageName(Number(key))}
</span>
{/snippet}
{#snippet stageModeLabel(key: string)}
{@const [stageId, mode] = key.split("-")}
<span class="stageModeLabel">
<ModeImage mode={mode as ModeShort} size={20} />
<StageImage
stageId={Number(stageId) as StageId}
width={36}
class="stageImage"
/>
{stageName(Number(stageId))}
</span>
{/snippet}
{#snippet statsTable(
rows: StatsRow[],
label: import("svelte").Snippet<[string]>,
)}
{#if rows.length === 0}
<div class="empty">{m.scrims_mapByMap_stats_empty()}</div>
{:else}
<Table>
<thead>
<tr>
<th>{m.scrims_mapByMap_stats_col_label()}</th>
<th class="cellNum">{m.scrims_mapByMap_stats_col_wins()}</th>
<th class="cellNum">{m.scrims_mapByMap_stats_col_losses()}</th>
<th class="cellNum">{m.scrims_mapByMap_stats_col_winPct()}</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.key)}
<tr>
<td class="labelCell">{@render label(row.key)}</td>
<td class="cellNum">{row.wins}</td>
<td class="cellNum">{row.losses}</td>
<td class="cellNum">{Math.round(row.winRate * 100)}%</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
{/snippet}
<style>
.root {
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--s-3);
}
.toggleRow {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-sm);
}
.labelCell {
white-space: nowrap;
}
.cellNum {
width: 4rem;
text-align: right;
font-variant-numeric: tabular-nums;
}
.empty {
color: var(--color-text-high);
font-style: italic;
}
.stageModeLabel {
display: inline-flex;
align-items: center;
gap: var(--s-2);
& > :global(*) {
flex-shrink: 0;
}
}
.stageModeLabel :global(.stageImage) {
border-radius: var(--radius-field);
}
</style>

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import MatchResultTab from "#lib/components/match-page/MatchResultTab.svelte";
import MatchRosterTab from "#lib/components/match-page/MatchRosterTab.svelte";
import MatchTabs from "#lib/components/match-page/MatchTabs.svelte";
import {
TAB_KEYS,
type MatchTabsKey,
} from "#lib/components/match-page/match-page-constants.ts";
import type { TimelineMap } from "#lib/components/match-page/MatchTimeline.svelte";
import { m } from "#lib/paraglide/messages.js";
import { databaseTimestampToJavascriptTimestamp } from "#lib/utils/dates.ts";
import { teamPage } from "#lib/utils/urls.ts";
import * as Scrim from "../Scrim.ts";
import type { ScrimPageData } from "../scrims.remote.ts";
import type { ScrimPost } from "../scrims-types.ts";
import ScrimMatchActionTab from "./ScrimMatchActionTab.svelte";
import ScrimMatchStatsTab from "./ScrimMatchStatsTab.svelte";
interface Props {
data: ScrimPageData;
}
let { data }: Props = $props();
const acceptedRequest = $derived(data.post.requests[0]);
const tabs = $derived.by(() => {
const resolved: MatchTabsKey[] = [TAB_KEYS.ROSTERS];
if (!data.mapByMap.locked) {
resolved.push(TAB_KEYS.ACTION);
}
if (data.mapByMap.maps.length > 0) {
resolved.push(TAB_KEYS.RESULT);
}
if (
data.mapByMap.maps.some((map) => map.reportedAt !== null) &&
data.mapByMap.viewerSide !== null
) {
resolved.push(TAB_KEYS.STATS);
}
return resolved;
});
function mapTeam(team: ScrimPost["team"]) {
if (!team) return undefined;
return {
id: 0,
name: team.name,
url: teamPage(team.customUrl),
avatar: team.avatarUrl ?? undefined,
};
}
const timelineMaps = $derived<TimelineMap[]>(
data.mapByMap.maps
.filter((map) => map.winnerSide !== null && map.reportedAt !== null)
.map((map) => ({
stageId: map.stageId,
mode: map.mode,
timestamp: databaseTimestampToJavascriptTimestamp(map.reportedAt!),
winner: map.winnerSide === "ALPHA" ? ("ALPHA" as const) : ("BRAVO" as const),
rosters: {
alpha: data.post.users,
bravo: acceptedRequest.users,
},
})),
);
</script>
<MatchTabs {tabs}>
<MatchRosterTab
teams={[
{
team: mapTeam(data.post.team),
defaultName: m.q_match_groupAlpha(),
members: data.post.users,
},
{
team: mapTeam(acceptedRequest.team),
defaultName: m.q_match_groupBravo(),
members: acceptedRequest.users,
},
]}
/>
<ScrimMatchActionTab {data} />
<MatchResultTab
teams={{
alpha: {
name: data.post.team
? Scrim.sideDisplayName(data.post)
: m.q_match_groupAlpha(),
avatar: data.post.team?.avatarUrl ?? undefined,
},
bravo: {
name: acceptedRequest.team
? Scrim.sideDisplayName(acceptedRequest)
: m.q_match_groupBravo(),
avatar: acceptedRequest.team?.avatarUrl ?? undefined,
},
}}
maps={timelineMaps}
isOngoing={!data.mapByMap.locked && data.mapByMap.currentMap !== null}
/>
<ScrimMatchStatsTab {data} />
</MatchTabs>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import Main from "#lib/components/Main.svelte";
import MatchPage from "#lib/components/match-page/MatchPage.svelte";
import { chatUi } from "#lib/features/chat/chat-state.svelte.ts";
import { setUserCardContext } from "#lib/features/user-page/user-card-context.ts";
import { getScrim } from "../scrims.remote.ts";
import ScrimMatchBanner from "./ScrimMatchBanner.svelte";
import ScrimMatchHeader from "./ScrimMatchHeader.svelte";
import ScrimMatchTabs from "./ScrimMatchTabs.svelte";
interface Props {
scrimPostId: number;
}
let { scrimPostId }: Props = $props();
const data = $derived(await getScrim({ scrimPostId }));
setUserCardContext({
userCards: () => data.userCards,
});
// the layout's chat sidebar auto-opens this scrim's room
$effect(() => {
const chatRoomId = data.chatRoomId;
if (chatRoomId === undefined) return;
chatUi.routeChatRoomId = chatRoomId;
if (chatUi.openRoomId === null) {
chatUi.openRoomId = chatRoomId;
}
return () => {
chatUi.routeChatRoomId = null;
if (chatUi.openRoomId === chatRoomId) {
chatUi.openRoomId = null;
}
};
});
</script>
<Main>
<MatchPage>
<ScrimMatchHeader {data} />
<ScrimMatchBanner {data} />
<ScrimMatchTabs {data} />
</MatchPage>
</Main>

View File

@@ -0,0 +1,419 @@
<script lang="ts">
import type { ModeShort } from "@sendou/in-game-lists/types";
import { Download, EyeOff, MessageCircleMore, Trash, Upload } from "@lucide/svelte";
import { Button, Dialog, LinkButton, Popover } from "@sendou/components";
import { formatDistance } from "date-fns";
import ConfirmDialog from "#lib/components/ConfirmDialog.svelte";
import LocaleTime from "#lib/components/LocaleTime.svelte";
import ModeImage from "#lib/components/ModeImage.svelte";
import TimePopover from "#lib/components/TimePopover.svelte";
import Avatar from "#lib/components/Avatar.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { m } from "#lib/paraglide/messages.js";
import type { CommonUser } from "#lib/server/kysely.ts";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import { scrimPage, tournamentRegisterPage } from "#lib/utils/urls.ts";
import {
cancelScrimRequest,
deleteScrimPost,
getScrimPosts,
} from "../scrims.remote.ts";
import type { ScrimPost } from "../scrims-types.ts";
import { formatFlexTimeDisplay } from "../scrims-utils.ts";
import ScrimCardShell from "./ScrimCardShell.svelte";
import ScrimExpandableText from "./ScrimExpandableText.svelte";
import ScrimRequestMembersList from "./ScrimRequestMembersList.svelte";
import ScrimRequestModal from "./ScrimRequestModal.svelte";
import ScrimTeamAvatar from "./ScrimTeamAvatar.svelte";
import ScrimTeamMembersPopover from "./ScrimTeamMembersPopover.svelte";
interface Props {
post: ScrimPost;
action?: "DELETE" | "REQUEST" | "VIEW_REQUEST" | "CONTACT";
isFilteredOut?: boolean;
autoScrollIntoView?: boolean;
/** Cleared the pending-request highlight after the auto scroll ran. */
onAutoScrolled?: () => void;
/** The viewer's teams, for the request modal. */
teams: Array<{ id: number; name: string; members: Array<CommonUser> }>;
}
let {
post,
action,
isFilteredOut,
autoScrollIntoView,
onAutoScrolled,
teams,
}: Props = $props();
const user = $derived(loggedInUser());
const owner = $derived(
post.users.find((postUser) => postUser.isOwner) ?? post.users[0],
);
const isPickup = $derived(!post.team?.name);
const teamName = $derived(post.team?.name ?? owner.username);
const flexTimeDisplay = $derived(
post.rangeEndsAt
? formatFlexTimeDisplay(post.startsAt, post.rangeEndsAt)
: null,
);
let isRequestModalOpen = $state(false);
let isViewRequestModalOpen = $state(false);
const userRequest = $derived(
post.requests.find((request) =>
request.users.some((requestUser) => user?.id === requestUser.id),
),
);
function autoScroll(node: HTMLElement) {
if (!autoScrollIntoView) return;
// deferred so it runs after the router's scroll to top
const timeout = setTimeout(() => {
node.scrollIntoView({ behavior: "smooth", block: "center" });
onAutoScrolled?.();
}, 0);
return () => clearTimeout(timeout);
}
function getModesList(maps: string): ModeShort[] {
if (maps === "SZ") {
return ["SZ"];
}
if (maps === "RANKED") {
return ["SZ", "TC", "RM", "CB"];
}
return ["TW", "SZ", "TC", "RM", "CB"];
}
const timePopoverFooterText = $derived(
m.scrims_postModal_footer({
time: formatDistance(
databaseTimestampToDate(post.createdAt),
new Date(),
{ addSuffix: true },
),
}),
);
</script>
<div {@attach autoScroll}>
<ScrimCardShell
{isPickup}
{teamName}
ownerUsername={owner.username}
footerClass={isFilteredOut ? "filteredFooter" : undefined}
>
{#snippet avatar()}
<ScrimTeamAvatar teamAvatarUrl={post.team?.avatarUrl} {teamName} {owner} />
{/snippet}
{#snippet rightIcons()}
{#if post.isPrivate}
<Popover>
{#snippet trigger(triggerProps)}
<button
type="button"
class="iconButton"
data-testid="limited-visibility-popover"
{...triggerProps}
>
<EyeOff class="usersIcon" />
</button>
{/snippet}
{m.scrims_limitedVisibility()}
</Popover>
{/if}
<ScrimTeamMembersPopover users={post.users} />
{/snippet}
<div class="infoRow">
{@render infoItem("Start", startTime)}
{#if flexTimeDisplay}
{@render infoItem("Flex", flexText)}
{/if}
{#if post.divs}
{@render infoItem("Div", divsText)}
{/if}
{#if post.maps || post.mapsTournament}
{@render infoItem("Modes", modesContent)}
{/if}
</div>
{#if post.text}
<ScrimExpandableText text={post.text} />
{/if}
{#snippet footer()}
{@render actionButtons()}
{/snippet}
</ScrimCardShell>
</div>
{#snippet infoItem(label: string, content: import("svelte").Snippet)}
<div class="infoItem">
<div class="infoLabel">{label}</div>
<div class="infoValue">{@render content()}</div>
</div>
{/snippet}
{#snippet startTime()}
{#if !post.isScheduledForFuture}
{#if post.canceled}
<div class="canceledContainer">
<span class="strikethrough">{m.scrims_now()}</span>
<span class="canceledLabel">Canceled</span>
</div>
{:else}
{m.scrims_now()}
{/if}
{:else if post.canceled}
<div class="canceledContainer">
<span class="strikethrough">
<TimePopover
date={databaseTimestampToDate(post.startsAt)}
options={{ hour: "numeric", minute: "numeric" }}
underline={false}
footerText={timePopoverFooterText}
/>
</span>
<span class="canceledLabel">Canceled</span>
</div>
{:else}
<TimePopover
date={databaseTimestampToDate(post.startsAt)}
options={{ hour: "numeric", minute: "numeric" }}
underline={false}
footerText={timePopoverFooterText}
/>
{/if}
{/snippet}
{#snippet flexText()}
{flexTimeDisplay}
{/snippet}
{#snippet divsText()}
{post.divs!.max === post.divs!.min
? post.divs!.max
: `${post.divs!.min}-${post.divs!.max}`}
{/snippet}
{#snippet modesContent()}
{#if post.mapsTournament}
{@const tournament = post.mapsTournament}
<Popover>
{#snippet trigger(triggerProps)}
<button
type="button"
class="iconButton tournamentPopoverTrigger"
data-testid="tournament-popover-trigger"
{...triggerProps}
>
<Avatar
size="xxxsm"
url={tournament.avatarUrl ?? undefined}
alt={tournament.name}
/>
</button>
{/snippet}
<div class="stack sm text-center">
<a
href={`${tournamentRegisterPage(tournament.id)}?tab=description`}
class="text-theme text-xxs"
>
{tournament.name}
</a>
</div>
</Popover>
{:else}
{#each getModesList(post.maps!) as mode (mode)}
<ModeImage {mode} size={18} />
{/each}
{/if}
{/snippet}
{#snippet actionButtons()}
{#if action === "REQUEST"}
<Button
size="small"
onclick={() => {
isRequestModalOpen = true;
}}
testId="request-scrim-button"
>
{#snippet icon()}<Upload />{/snippet}
{m.scrims_actions_request()}
</Button>
{#if isRequestModalOpen}
<ScrimRequestModal
{post}
{teams}
close={() => {
isRequestModalOpen = false;
}}
/>
{/if}
{:else if action === "VIEW_REQUEST"}
<Button
size="small"
onclick={() => {
isViewRequestModalOpen = true;
}}
variant="outlined"
testId="view-request-button"
>
{#snippet icon()}<Download />{/snippet}
{m.scrims_actions_viewRequest()}
</Button>
{#if isViewRequestModalOpen && userRequest}
<Dialog
heading={m.scrims_cancelRequestModal_title()}
onClose={() => {
isViewRequestModalOpen = false;
}}
>
<div class="stack md">
<ScrimRequestMembersList users={userRequest.users} />
{#if userRequest.message}
<div>
<div class="text-sm font-semi-bold mb-1">
{m.scrims_requestModal_message_label()}
</div>
<div class="text-lighter">{userRequest.message}</div>
</div>
{/if}
{#if userRequest.startsAt}
<div>
<div class="text-sm font-semi-bold mb-1">
{m.scrims_requestModal_at_label()}
</div>
<LocaleTime
date={userRequest.startsAt}
options={{
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "numeric",
}}
class="text-lighter"
/>
</div>
{/if}
<Button
variant="destructive"
onclick={() =>
cancelScrimRequest({
scrimPostRequestId: userRequest!.id,
}).updates(getScrimPosts)}
>
{#snippet icon()}<Trash />{/snippet}
{m.common_actions_cancel()}
</Button>
</div>
</Dialog>
{/if}
{:else if action === "CONTACT"}
<LinkButton href={scrimPage(post.id)} size="small">
{#snippet icon()}<MessageCircleMore />{/snippet}
{m.scrims_actions_contact()}
</LinkButton>
{:else if action === "DELETE"}
<ConfirmDialog
dialogHeading={m.scrims_deleteModal_title()}
submitButtonText={m.common_actions_delete()}
onConfirm={() =>
deleteScrimPost({ scrimPostId: post.id }).updates(getScrimPosts)}
>
{#snippet trigger(triggerProps)}
<Button size="small" variant="destructive" {...triggerProps}>
{#snippet icon()}<Trash />{/snippet}
{m.common_actions_delete()}
</Button>
{/snippet}
</ConfirmDialog>
{/if}
{/snippet}
<style>
.iconButton {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--color-text);
border-radius: var(--radius-field);
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.iconButton :global(.usersIcon) {
width: 18px;
height: 18px;
}
.infoRow {
display: flex;
gap: var(--s-6);
padding-inline: var(--s-4);
padding-bottom: var(--s-3);
flex-wrap: wrap;
}
.infoItem {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.infoLabel {
text-transform: uppercase;
color: var(--color-text-high);
font-weight: var(--weight-bold);
font-size: var(--font-2xs);
}
.infoValue {
font-weight: var(--weight-semi);
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-xs);
}
.tournamentPopoverTrigger {
height: auto;
}
.canceledContainer {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.strikethrough {
text-decoration: line-through;
& :global(button) {
text-decoration: line-through;
}
}
.canceledLabel {
color: var(--color-error);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
}
</style>

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { Check } from "@lucide/svelte";
import { Button, Popover } from "@sendou/components";
import ConfirmDialog from "#lib/components/ConfirmDialog.svelte";
import { dateTimeFormat } from "#lib/modules/intl/date-time-format.ts";
import { m } from "#lib/paraglide/messages.js";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import { acceptScrimRequest, getScrimPosts } from "../scrims.remote.ts";
import type { ScrimPostRequest } from "../scrims-types.ts";
import ScrimCardShell from "./ScrimCardShell.svelte";
import ScrimExpandableText from "./ScrimExpandableText.svelte";
import ScrimTeamAvatar from "./ScrimTeamAvatar.svelte";
import ScrimTeamMembersPopover from "./ScrimTeamMembersPopover.svelte";
interface Props {
request: ScrimPostRequest;
postStartTime: number;
canAccept: boolean;
showFooter?: boolean;
}
let { request, postStartTime, canAccept, showFooter = true }: Props = $props();
const owner = $derived(
request.users.find((user) => user.isOwner) ?? request.users[0],
);
const isPickup = $derived(!request.team?.name);
const teamName = $derived(request.team?.name ?? owner.username);
const confirmedTime = $derived(
request.startsAt
? databaseTimestampToDate(request.startsAt)
: databaseTimestampToDate(postStartTime),
);
const formattedTime = $derived(
dateTimeFormat({ hour: "numeric", minute: "2-digit" }).formatter.format(
confirmedTime,
),
);
</script>
<ScrimCardShell
isRequestCard
{isPickup}
{teamName}
ownerUsername={owner.username}
footerClass="requestFooter"
{showFooter}
>
{#snippet avatar()}
<ScrimTeamAvatar
teamAvatarUrl={request.team?.avatarUrl}
{teamName}
{owner}
/>
{/snippet}
{#snippet rightIcons()}
<ScrimTeamMembersPopover users={request.users} />
{/snippet}
{#if request.message}
<ScrimExpandableText text={request.message} />
{/if}
{#snippet footer()}
{#if canAccept}
<ConfirmDialog
dialogHeading={m.scrims_acceptModal_title({ groupName: teamName })}
description={m.scrims_autoCancelInfo()}
submitButtonVariant="primary"
submitButtonText={m.common_actions_confirm()}
onConfirm={() =>
acceptScrimRequest({ scrimPostRequestId: request.id }).updates(
getScrimPosts,
)}
>
{#snippet trigger(triggerProps)}
<Button
size="small"
testId="confirm-modal-trigger-button"
{...triggerProps}
>
{#snippet icon()}<Check />{/snippet}
{m.scrims_acceptModal_confirmFor({ time: formattedTime })}
</Button>
{/snippet}
</ConfirmDialog>
{:else}
<Popover>
{#snippet trigger(triggerProps)}
<Button size="small" {...triggerProps}>
{m.scrims_acceptModal_confirmFor({ time: formattedTime })}
</Button>
{/snippet}
{m.scrims_acceptModal_prevented()}
</Popover>
{/if}
{/snippet}
</ScrimCardShell>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
import { m } from "#lib/paraglide/messages.js";
import type { ScrimPostUser } from "../scrims-types.ts";
interface Props {
users: ScrimPostUser[];
}
let { users }: Props = $props();
const cards = getUserCardContext();
const sortedUsers = $derived(
[...users].sort((a, b) => Number(b.isOwner) - Number(a.isOwner)),
);
</script>
<div class="stack md">
{#each sortedUsers as user (user.id)}
<UserCard userId={user.id} withMutualFriends>
<span class="stack horizontal sm items-center">
<NoteAvatar
sentiment={cards?.userCards()?.get(user.id)?.privateNote?.sentiment}
size="sm"
>
<Avatar size="xs" {user} />
</NoteAvatar>
<span>
{user.username}
{#if user.isOwner}
<div class="text-lighter text-xs">
{m.scrims_cancelRequestModal_requester()}
</div>
{/if}
</span>
</span>
</UserCard>
{/each}
</div>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { Dialog } from "@sendou/components";
import * as v from "valibot";
import Divider from "#lib/components/Divider.svelte";
import FormMessage from "#lib/components/FormMessage.svelte";
import FormField from "#lib/form/FormField.svelte";
import SendouForm from "#lib/form/SendouForm.svelte";
import { m } from "#lib/paraglide/messages.js";
import { getLocale } from "#lib/paraglide/runtime.js";
import type { CommonUser } from "#lib/server/kysely.ts";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import { SCRIM } from "../scrims-constants.ts";
import { getScrimPosts, newScrimRequest } from "../scrims.remote.ts";
import { type newRequestSchema, scrimRequestFormSchema } from "../scrims-schemas.ts";
import type { ScrimPost } from "../scrims-types.ts";
import { generateTimeOptions } from "../scrims-utils.ts";
import WithFormField from "./WithFormField.svelte";
interface Props {
post: ScrimPost;
teams: Array<{ id: number; name: string; members: Array<CommonUser> }>;
close: () => void;
}
let { post, teams, close }: Props = $props();
const timeFormatter = new Intl.DateTimeFormat(getLocale(), {
hour: "numeric",
minute: "numeric",
});
// svelte-ignore state_referenced_locally -- the modal is remounted per open, the post can't change under it
const timeOptions = post.rangeEndsAt
? generateTimeOptions(
databaseTimestampToDate(post.startsAt),
databaseTimestampToDate(post.rangeEndsAt),
).map((timestamp) => ({
value: String(timestamp),
label: timeFormatter.format(new Date(timestamp)),
}))
: [];
function nullFilledArray(length: number) {
return Array.from({ length }, () => null);
}
async function handleSubmit(values: Record<string, unknown>) {
const result = await newScrimRequest(
values as v.InferInput<typeof newRequestSchema>,
).updates(getScrimPosts);
if (result?.fieldErrors) {
return {
fieldErrors: result.fieldErrors as unknown as Record<string, string>,
};
}
return undefined;
}
</script>
<Dialog heading={m.scrims_requestModal_title()} onClose={close}>
<SendouForm
schema={scrimRequestFormSchema}
defaultValues={{
scrimPostId: post.id,
from:
teams.length > 0
? { mode: "TEAM", teamId: teams[0].id }
: {
mode: "PICKUP",
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
) as unknown as number[],
},
message: "",
at: post.rangeEndsAt && timeOptions[0] ? timeOptions[0].value : null,
}}
onSubmit={handleSubmit}
onSuccess={close}
>
<div class="font-semi-bold text-lighter italic">
{new Intl.ListFormat(getLocale()).format(
post.users.map((u) => u.username),
)}
</div>
{#if post.text}
<div class="text-sm text-lighter italic">{post.text}</div>
{/if}
<Divider />
<FormField name="from">
{#snippet children(props)}
<WithFormField usersTeams={teams} {...props} />
{/snippet}
</FormField>
{#if post.rangeEndsAt}
<FormField name="at" options={timeOptions} />
{/if}
<FormField name="message" />
<FormMessage type="info">{m.scrims_autoCancelInfo()}</FormMessage>
</SendouForm>
</Dialog>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
import type { ScrimPostUser } from "../scrims-types.ts";
interface Props {
teamAvatarUrl: string | null | undefined;
teamName: string;
owner: ScrimPostUser;
}
let { teamAvatarUrl, teamName, owner }: Props = $props();
const cards = getUserCardContext();
const cardData = $derived(cards?.userCards()?.get(owner.id));
</script>
{#if teamAvatarUrl}
<Avatar size="xs" url={teamAvatarUrl} alt={teamName} />
{:else}
<UserCard userId={owner.id} withMutualFriends>
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
<Avatar size="xs" user={owner} alt={owner.username} />
</NoteAvatar>
</UserCard>
{/if}

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { Users } from "@lucide/svelte";
import { Popover } from "@sendou/components";
import Avatar from "#lib/components/Avatar.svelte";
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
import UserCard from "#lib/features/user-page/components/UserCard.svelte";
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
import type { ScrimPostUser } from "../scrims-types.ts";
interface Props {
users: ScrimPostUser[];
}
let { users }: Props = $props();
const cards = getUserCardContext();
</script>
<Popover>
{#snippet trigger(triggerProps)}
<button type="button" class="minimalButton" {...triggerProps}>
<Users class="usersIcon" />
</button>
{/snippet}
<div class="stack md">
{#each users as user (user.id)}
<UserCard userId={user.id} withMutualFriends>
<span class="stack horizontal sm items-center">
<NoteAvatar
sentiment={cards?.userCards()?.get(user.id)?.privateNote
?.sentiment}
size="xs"
>
<Avatar size="xxs" {user} />
</NoteAvatar>
{user.username}
</span>
</UserCard>
{/each}
</div>
</Popover>
<style>
.minimalButton {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--color-text);
border-radius: var(--radius-field);
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.minimalButton :global(.usersIcon) {
width: 18px;
height: 18px;
}
</style>

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { Download, Funnel } from "@lucide/svelte";
import { Button } from "@sendou/components";
import LocaleTime from "#lib/components/LocaleTime.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { m } from "#lib/paraglide/messages.js";
import type { CommonUser } from "#lib/server/kysely.ts";
import * as Scrim from "../Scrim.ts";
import type { ScrimFilters, ScrimPost } from "../scrims-types.ts";
import ScrimPostCard from "./ScrimPostCard.svelte";
interface Props {
posts: ScrimPost[];
filters: ScrimFilters;
pendingRequestPostId: number | null;
autoScrollToPostId: number | null;
onAutoScrolled?: () => void;
teams: Array<{ id: number; name: string; members: Array<CommonUser> }>;
}
let {
posts,
filters,
pendingRequestPostId,
autoScrollToPostId,
onAutoScrolled,
teams,
}: Props = $props();
const user = $derived(loggedInUser());
let showFiltered = $state(false);
// svelte-ignore state_referenced_locally -- the pending request highlight seeds the initial state only
let showRequestPending = $state(pendingRequestPostId !== null);
const filteredPosts = $derived(
posts.filter((post) => Scrim.applyFilters(post, filters)),
);
const pendingRequestsCount = $derived(
filteredPosts.filter((post) =>
post.requests.some((request) =>
request.users.some((requestUser) => user?.id === requestUser.id),
),
).length,
);
const filteredCount = $derived(posts.length - filteredPosts.length);
function hasRequested(post: ScrimPost) {
return post.requests.some((request) =>
request.users.some((requestUser) => user?.id === requestUser.id),
);
}
function getAction(post: ScrimPost) {
if (!user) return undefined;
if (hasRequested(post)) return "VIEW_REQUEST" as const;
if (post.requests.length === 0) return "REQUEST" as const;
return undefined;
}
</script>
<div class="stack md">
<div class="stack xxs">
<h2 class="text-sm">
<LocaleTime
date={posts[0].startsAt}
options={{
day: "numeric",
month: "numeric",
weekday: "long",
}}
/>
</h2>
{#if user && (filteredCount > 0 || pendingRequestsCount > 0)}
<div class="filterButtons">
{#if filteredCount > 0}
<Button
variant="minimal"
size="miniscule"
onclick={() => {
showFiltered = !showFiltered;
}}
class={showFiltered ? "active" : undefined}
>
{#snippet icon()}<Funnel />{/snippet}
{showFiltered
? m.scrims_filters_hideFiltered({ count: filteredCount })
: m.scrims_filters_showFiltered({ count: filteredCount })}
</Button>
{/if}
{#if pendingRequestsCount > 0}
<Button
variant="minimal"
size="miniscule"
onclick={() => {
showRequestPending = !showRequestPending;
}}
class={showRequestPending ? "active" : undefined}
testId="toggle-pending-requests-button"
>
{#snippet icon()}<Download />{/snippet}
{showRequestPending
? m.scrims_filters_hidePendingRequests({
count: pendingRequestsCount,
})
: m.scrims_filters_showPendingRequests({
count: pendingRequestsCount,
})}
</Button>
{/if}
</div>
{/if}
</div>
<div class="cardsGrid">
{#each showFiltered ? posts : filteredPosts as post (post.id)}
{#if !(hasRequested(post) && !showRequestPending)}
<ScrimPostCard
{post}
action={getAction(post)}
isFilteredOut={showFiltered && !Scrim.applyFilters(post, filters)}
autoScrollIntoView={post.id === autoScrollToPostId}
{onAutoScrolled}
{teams}
/>
{/if}
{/each}
</div>
</div>
<style>
.cardsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--s-4);
}
.filterButtons {
display: flex;
gap: var(--s-6);
align-items: center;
& :global(button:not(.active)) {
color: var(--color-text-high);
}
}
</style>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { Star } from "@lucide/svelte";
import { Button } from "@sendou/components";
import FilterBar from "#lib/components/filter-bar/FilterBar.svelte";
import { m } from "#lib/paraglide/messages.js";
import * as Scrim from "../Scrim.ts";
import { getScrimPosts, persistScrimFilters } from "../scrims.remote.ts";
import type { ScrimFilters } from "../scrims-types.ts";
import DivsFilterPopover from "./DivsFilterPopover.svelte";
import TimeRangeFilterPopover from "./TimeRangeFilterPopover.svelte";
interface Props {
filters: ScrimFilters;
canSaveAsDefault: boolean;
writeFilters: (partial: Partial<ScrimFilters>) => void;
}
let { filters, canSaveAsDefault, writeFilters }: Props = $props();
let persistPending = $state(false);
async function saveAsDefault() {
persistPending = true;
try {
await persistScrimFilters({
filters: $state.snapshot(filters) as ScrimFilters,
}).updates(getScrimPosts);
} finally {
persistPending = false;
}
}
</script>
{#snippet weekdayPopover()}
<TimeRangeFilterPopover
name="weekdayTimes"
value={filters.weekdayTimes}
onChange={(timeRange) => writeFilters({ weekdayTimes: timeRange })}
/>
{/snippet}
{#snippet weekendPopover()}
<TimeRangeFilterPopover
name="weekendTimes"
value={filters.weekendTimes}
onChange={(timeRange) => writeFilters({ weekendTimes: timeRange })}
/>
{/snippet}
{#snippet divsPopover()}
<DivsFilterPopover
value={filters.divs}
onChange={(divs) => writeFilters({ divs })}
/>
{/snippet}
{#snippet actions()}
{#if canSaveAsDefault}
<Button
disabled={persistPending}
onclick={saveAsDefault}
testId="save-filters-as-default-button"
>
{#snippet icon()}<Star />{/snippet}
{m.common_filterBar_saveAsDefault()}
</Button>
{/if}
{/snippet}
<FilterBar
pills={[
{
key: "weekdayTimes",
name: m.scrims_filters_weekdayTimes(),
formattedValue: filters.weekdayTimes
? `${filters.weekdayTimes.start}–${filters.weekdayTimes.end}`
: null,
onRemove: () => writeFilters({ weekdayTimes: null }),
testId: "weekday-times-filter",
popover: weekdayPopover,
},
{
key: "weekendTimes",
name: m.scrims_filters_weekendTimes(),
formattedValue: filters.weekendTimes
? `${filters.weekendTimes.start}–${filters.weekendTimes.end}`
: null,
onRemove: () => writeFilters({ weekendTimes: null }),
testId: "weekend-times-filter",
popover: weekendPopover,
},
{
key: "divs",
name: m.scrims_filters_divs(),
formattedValue: filters.divs
? `${filters.divs.max}–${filters.divs.min}`
: null,
onRemove: () => writeFilters({ divs: null }),
testId: "divs-filter",
popover: divsPopover,
},
]}
onReset={!Scrim.filtersAreDefault(filters)
? () =>
writeFilters({
weekdayTimes: null,
weekendTimes: null,
divs: null,
})
: undefined}
{actions}
/>

View File

@@ -0,0 +1,245 @@
<script lang="ts">
import { Check, Download, Megaphone } from "@lucide/svelte";
import { LinkButton, Tab, TabList, TabPanel, Tabs } from "@sendou/components";
import { format } from "date-fns";
import * as R from "remeda";
import LocaleTime from "#lib/components/LocaleTime.svelte";
import Main from "#lib/components/Main.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { setUserCardContext } from "#lib/features/user-page/user-card-context.ts";
import { searchParamsState } from "#lib/modules/search-params/search-params-state.svelte.ts";
import { m } from "#lib/paraglide/messages.js";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import { associationsPage } from "#lib/utils/urls.ts";
import { getScrimPosts } from "../scrims.remote.ts";
import { scrimsSearchParams } from "../scrims-search-params.ts";
import type { ScrimFilters, ScrimPost } from "../scrims-types.ts";
import ScrimPostCard from "./ScrimPostCard.svelte";
import ScrimRequestCard from "./ScrimRequestCard.svelte";
import ScrimsDaySection from "./ScrimsDaySection.svelte";
import ScrimsFilters from "./ScrimsFilters.svelte";
const user = $derived(loggedInUser());
const params = searchParamsState(scrimsSearchParams);
const data = $derived(
await getScrimPosts({
weekdayTimes: params.current.weekdayTimes,
weekendTimes: params.current.weekendTimes,
divs: params.current.divs,
useDefaults: params.current.useDefaults,
}),
);
setUserCardContext({
userCards: () => data.userCards,
});
const autoScrollToPostId = $derived(params.current.pendingRequestPostId);
// kept in state because the search param is cleared after the auto scroll
let pendingRequestPostId = $state<number | null>(null);
$effect(() => {
if (autoScrollToPostId !== null) {
pendingRequestPostId = autoScrollToPostId;
}
});
function writeFilters(partial: Partial<ScrimFilters>) {
params.set({ ...data.filters, ...partial, useDefaults: false });
}
function postsByDay(posts: ScrimPost[]) {
return Object.entries(
R.groupBy(posts, (post) =>
format(databaseTimestampToDate(post.startsAt), "yyyy-MM-dd"),
),
).sort(([a], [b]) => a.localeCompare(b));
}
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
</script>
<Main>
<div class="stack lg">
<div class="stack horizontal sm items-center flex-wrap">
<LinkButton
size="small"
href={associationsPage()}
class={!user ? "invisible" : undefined}
variant="outlined"
>
{m.scrims_associations_title()}
</LinkButton>
<ScrimsFilters
filters={data.filters}
canSaveAsDefault={data.canSaveAsDefault}
{writeFilters}
/>
</div>
{#key pendingRequestPostId}
<Tabs
defaultSelectedKey={pendingRequestPostId !== null
? "available"
: data.posts.owned.length > 0
? "owned"
: data.posts.booked.length > 0
? "booked"
: "available"}
>
{#if user}
<TabList sticky>
<Tab id="available" number={data.posts.neutral.length}>
{#snippet icon()}<Megaphone />{/snippet}
{m.scrims_tabs_available()}
</Tab>
<Tab id="owned" number={data.posts.owned.length}>
{#snippet icon()}<Download />{/snippet}
{m.scrims_tabs_owned()}
</Tab>
<Tab id="booked" number={data.posts.booked.length}>
{#snippet icon()}<Check />{/snippet}
{m.scrims_tabs_booked()}
</Tab>
</TabList>
{/if}
<TabPanel id="available">
{#if data.posts.neutral.length > 0}
<div class="stack lg">
{#each postsByDay(data.posts.neutral) as [day, dayPosts] (day)}
<ScrimsDaySection
posts={dayPosts}
filters={data.filters}
{pendingRequestPostId}
{autoScrollToPostId}
onAutoScrolled={() =>
params.set({ pendingRequestPostId: null })}
teams={data.teams}
/>
{/each}
</div>
{:else}
<div class="text-lighter text-lg font-semi-bold text-center mt-6">
{m.scrims_noneAvailable()}
</div>
{/if}
</TabPanel>
<TabPanel id="owned">
{#if data.posts.owned.length > 0}
<div class="stack lg">
{#each postsByDay(data.posts.owned) as [day, dayPosts] (day)}
<div class="stack md">
<h2 class="text-sm">
<LocaleTime
date={dayPosts[0].startsAt}
options={{
day: "numeric",
month: "numeric",
weekday: "long",
}}
/>
</h2>
<div class="stack lg">
{#each dayPosts as post (post.id)}
{@const isAccepted = post.requests.some(
(request) => request.isAccepted,
)}
{@const canDelete =
user &&
post.permissions.DELETE_POST.includes(user.id) &&
!isAccepted}
<div class="stack sm">
<ScrimPostCard
{post}
action={canDelete ? "DELETE" : undefined}
teams={data.teams}
/>
{#if post.requests.length > 0}
<div class="stack sm">
{#each post.requests as request (request.id)}
<ScrimRequestCard
{request}
postStartTime={post.startsAt}
canAccept={Boolean(
user &&
post.permissions.MANAGE_REQUESTS.includes(
user.id,
),
)}
/>
{/each}
</div>
{:else}
<div
class="text-lighter text-lg font-bold mt-2 text-center"
>
{m.scrims_noRequestsYet()}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div class="text-lighter text-lg font-semi-bold text-center mt-6">
{m.scrims_noOwnedPosts()}
</div>
{/if}
</TabPanel>
<TabPanel id="booked">
{#if data.posts.booked.length > 0}
<div class="stack lg">
{#each postsByDay(data.posts.booked) as [day, dayPosts] (day)}
<div class="stack md">
<h2 class="text-sm">
<LocaleTime
date={dayPosts[0].startsAt}
options={{
day: "numeric",
month: "numeric",
weekday: "long",
}}
/>
</h2>
<div class="stack lg">
{#each dayPosts as post (post.id)}
{@const acceptedRequest = post.requests.find(
(request) => request.isAccepted,
)}
<div class="stack sm">
<ScrimPostCard
{post}
action="CONTACT"
teams={data.teams}
/>
{#if acceptedRequest}
<ScrimRequestCard
request={acceptedRequest}
postStartTime={post.startsAt}
canAccept={false}
showFooter={false}
/>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div class="text-lighter text-lg font-semi-bold text-center mt-6">
{m.scrims_noBookedScrims()}
</div>
{/if}
</TabPanel>
</Tabs>
{/key}
<div class="mt-6 text-xs text-center text-lighter">
{m.calendar_inYourTimeZone()}
{timeZone}
</div>
</div>
</Main>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import FormField from "#lib/form/FormField.svelte";
import { getFormContext } from "#lib/form/form-context.ts";
const context = getFormContext();
const source = $derived(context.value("source") as string | null);
</script>
{#if source === "POOL"}
<FormField name="serializedPool" />
{:else if source === "TOURNAMENT"}
<FormField name="tournamentId" />
{/if}

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import * as v from "valibot";
import TimeRangeFormField from "#lib/form/fields/TimeRangeFormField.svelte";
import { m } from "#lib/paraglide/messages.js";
import { timeString } from "#lib/utils/schemas.ts";
import type { TimeRange } from "../scrims-types.ts";
interface Props {
name: string;
value: TimeRange | null;
onChange: (value: TimeRange | null) => void;
}
let { name, value, onChange }: Props = $props();
// svelte-ignore state_referenced_locally -- the filter value seeds the draft only
let draft = $state(value);
function handleChange(timeRange: TimeRange | null) {
draft = timeRange;
if (timeRange === null) {
onChange(null);
return;
}
if (
v.safeParse(timeString, timeRange.start).success &&
v.safeParse(timeString, timeRange.end).success
) {
onChange(timeRange);
}
}
</script>
<TimeRangeFormField
{name}
value={draft}
onChange={handleChange}
startLabel={m.forms_labels_start()}
endLabel={m.forms_labels_end()}
/>

View File

@@ -0,0 +1,254 @@
<script lang="ts">
import { Select, SelectItem } from "@sendou/components";
import * as R from "remeda";
import * as v from "valibot";
import FormMessage from "#lib/components/FormMessage.svelte";
import UserSearch from "#lib/components/UserSearch.svelte";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import FormFieldWrapper from "#lib/form/fields/FormFieldWrapper.svelte";
import { errorMessageId, translateFormText } from "#lib/form/form-utils.ts";
import { m } from "#lib/paraglide/messages.js";
import type { CommonUser } from "#lib/server/kysely.ts";
import { SCRIM } from "../scrims-constants.ts";
import type { fromSchema } from "../scrims-schemas.ts";
type FromValue = v.InferOutput<typeof fromSchema>;
const NEW_PICKUP_KEY = "PICKUP";
/** Keeps one long username from crowding out the rest of the pick-up */
const MAX_PICKUP_USERNAME_LENGTH = 12;
interface PickupRoster {
id: number;
users: Array<CommonUser>;
}
/** One entry of the select, carrying the form value selecting it results in. */
interface WithOption {
key: string;
label: string;
/** Members of the pick-up, shown under the label */
users?: string;
textValue: string;
value: FromValue;
}
interface Props {
usersTeams: Array<{
id: number;
name: string;
members: Array<CommonUser>;
}>;
/** Pick-up rosters the user recently posted with, offered as ready made options */
recentPickupRosters?: Array<PickupRoster>;
name: string;
value: unknown;
onChange: (value: unknown) => void;
error: string | undefined;
}
let {
usersTeams,
recentPickupRosters = [],
name,
value,
onChange,
error,
}: Props = $props();
const user = $derived(loggedInUser());
const translatedError = $derived(translateFormText(error));
/** Remounts the pick-up fields so they reload their initial users */
let pickupFieldsKey = $state(NEW_PICKUP_KEY);
const fromValue = $derived(value as FromValue | null);
function nullFilledArray(length: number) {
return Array.from({ length }, () => null);
}
const options = $derived<Array<WithOption>>([
...usersTeams.map((team) => ({
key: `TEAM_${team.id}`,
label: team.name,
textValue: team.name,
value: { mode: "TEAM" as const, teamId: team.id },
})),
{
key: NEW_PICKUP_KEY,
label:
recentPickupRosters.length > 0
? m.scrims_forms_with_pick_up_new()
: m.scrims_forms_with_pick_up(),
textValue:
recentPickupRosters.length > 0
? m.scrims_forms_with_pick_up_new()
: m.scrims_forms_with_pick_up(),
value: {
mode: "PICKUP" as const,
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
) as unknown as number[],
},
},
...recentPickupRosters.map((roster) => {
const usernames = roster.users.map((rosterUser) => rosterUser.username);
return {
key: `PICKUP_${roster.id}`,
label: m.scrims_forms_with_pick_up(),
users: usernames.map(truncateUsername).join(", "),
textValue: m.scrims_forms_with_pick_up_recent({
users: usernames.join(", "),
}),
value: { mode: "PICKUP" as const, users: rosterUsers(roster) },
};
}),
]);
function handleSelectionChange(key: string | number | null) {
const option = options.find((candidate) => candidate.key === key);
if (!option) return;
pickupFieldsKey = option.key;
onChange(option.value);
}
function handleUserChange(
selectedUser: { id: number } | null,
index: number,
) {
if (fromValue?.mode !== "PICKUP") return;
onChange({
mode: "PICKUP",
users: fromValue.users.map((memberId, j) =>
j === index ? selectedUser?.id : memberId,
),
});
}
const selectedKey = $derived.by(() => {
if (fromValue?.mode === "TEAM") return `TEAM_${fromValue.teamId}`;
const matchingRoster =
fromValue?.mode === "PICKUP"
? recentPickupRosters.find((roster) =>
hasSameUsers(roster, fromValue.users),
)
: undefined;
return matchingRoster ? `PICKUP_${matchingRoster.id}` : NEW_PICKUP_KEY;
});
/** Whether the pick-up as it stands still matches the roster it was filled from */
function hasSameUsers(roster: PickupRoster, userIds: Array<number | null>) {
const selectedUserIds = userIds.filter(
(userId) => typeof userId === "number",
);
return (
R.unique(selectedUserIds).length ===
R.unique(roster.users.map((rosterUser) => rosterUser.id)).length &&
selectedUserIds.every((userId) =>
roster.users.some((rosterUser) => rosterUser.id === userId),
)
);
}
function truncateUsername(username: string) {
return username.length > MAX_PICKUP_USERNAME_LENGTH
? `${username.slice(0, MAX_PICKUP_USERNAME_LENGTH).trimEnd()}…`
: username;
}
function rosterUsers(roster: PickupRoster) {
return [
...roster.users.map((rosterUser) => rosterUser.id),
...nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER - roster.users.length,
),
] as unknown as number[];
}
const fieldId = $props.id();
</script>
<FormFieldWrapper
id={fieldId}
{name}
error={fromValue?.mode === "TEAM" ? error : undefined}
>
<div class="withSelect">
<Select
label={m.scrims_forms_with_title()}
{selectedKey}
onSelectionChange={handleSelectionChange}
>
{#each options as option (option.key)}
<SelectItem id={option.key} textValue={option.textValue}>
<div class="option">
{option.label}
{#if option.users}
<span class="optionUsers">{option.users}</span>
{/if}
</div>
</SelectItem>
{/each}
</Select>
</div>
{#if fromValue?.mode === "PICKUP" && user}
<div class="stack md mt-4">
<UserSearch
initialUserId={user.id}
isDisabled
label={m.scrims_forms_with_user({ nth: 1 })}
/>
{#each fromValue.users as userId, i (`${pickupFieldsKey}-${i}`)}
<UserSearch
initialUserId={userId ?? undefined}
onChange={(selectedUser) => handleUserChange(selectedUser, i)}
isRequired={i < 3}
label={m.scrims_forms_with_user({ nth: i + 2 })}
/>
{/each}
{#if translatedError}
<FormMessage type="error" id={errorMessageId(name)}>
{translatedError}
</FormMessage>
{:else}
<FormMessage type="info">
{m.scrims_forms_with_explanation()}
</FormMessage>
{/if}
</div>
{/if}
</FormFieldWrapper>
<style>
.option {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
line-height: 1.25;
}
.optionUsers {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* the closed select has room for one line only */
.withSelect :global(button .option),
.withSelect :global(.selectValue .option) {
flex-direction: row;
align-items: center;
gap: var(--s-2);
}
</style>

View File

@@ -0,0 +1,963 @@
import invariant from "@sendou/utils/invariant";
import { logger } from "@sendou/utils/logger";
import { assertUnreachable } from "@sendou/utils/types";
import { add, sub } from "date-fns";
import * as R from "remeda";
import * as v from "valibot";
import * as AssociationRepository from "#lib/features/associations/AssociationRepository.server.ts";
import * as Association from "#lib/features/associations/core/Association.ts";
import {
type AuthenticatedUser,
getUser,
requireUser,
} from "#lib/features/auth/user.server.ts";
import { userIsBanned } from "#lib/features/ban/banned.server.ts";
import { ensureScrimChatRoom, markScrimChatRoomInactive, sendSystemMessage } from "#lib/features/chat/chat.server.ts";
import { notify } from "#lib/features/notifications/core/notify.server.ts";
import { resolveNotifications } from "#lib/features/notifications/core/resolve.server.ts";
import * as SQGroupRepository from "#lib/features/sendouq/SQGroupRepository.server.ts";
import * as TeamRepository from "#lib/features/team/TeamRepository.server.ts";
import { getMemberRoleType } from "#lib/features/team/team-utils.ts";
import * as UserCardRepository from "#lib/features/user-page/UserCardRepository.server.ts";
import * as UserRepository from "#lib/features/user-page/UserRepository.server.ts";
import { requirePermission } from "#lib/modules/permissions/guards.server.ts";
import * as Events from "#lib/server/events.ts";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "#lib/utils/dates.ts";
import {
ConcurrentModificationError,
DuplicateEntryError,
} from "#lib/utils/errors.ts";
import {
errorToast,
errorToastIfFalsy,
notFoundIfNullish,
} from "#lib/utils/respond.server.ts";
import { id } from "#lib/utils/schemas.ts";
import { toDBBoolean } from "#lib/utils/sql.ts";
import { command, query, requested } from "$app/server";
import * as Scrim from "./Scrim.ts";
import * as ScrimMapByMap from "./ScrimMapByMap.ts";
import * as ScrimMapListRepository from "./ScrimMapListRepository.server.ts";
import * as ScrimMapRepository from "./ScrimMapRepository.server.ts";
import * as ScrimPickupRosterRepository from "./ScrimPickupRosterRepository.server.ts";
import * as ScrimPostRepository from "./ScrimPostRepository.server.ts";
import { LUTI_DIVS, SCRIM } from "./scrims-constants.ts";
import {
acceptRequestSchema,
cancelRequestSchema,
cancelScrimFormSchema,
deletePostSchema,
type fromSchema,
newRequestSchema,
persistScrimFiltersSchema,
pickMapFormSchema,
type RANGE_END_OPTIONS,
removeMapListSchema,
replayMapSchema,
reportMapSchema,
scrimsFiltersSchema,
scrimsNewFormSchema,
submitMapListFormSchema,
undoMapSchema,
} from "./scrims-schemas.ts";
import type { LutiDiv } from "./scrims-types.ts";
import {
dividePosts,
generateTimeOptions,
parseMapPoolInput,
serializeLutiDiv,
} from "./scrims-utils.ts";
const getScrimPostsSchema = v.object({
...scrimsFiltersSchema.entries,
useDefaults: v.boolean(),
});
/** The `/scrims` page data: visible posts divided into tabs plus filter state. */
export const getScrimPosts = query(
getScrimPostsSchema,
async ({ weekdayTimes, weekendTimes, divs, useDefaults }) => {
const user = getUser();
const associations = user
? await AssociationRepository.findByMemberUserId(user.id)
: null;
const filtersFromSearchParams = { weekdayTimes, weekendTimes, divs };
// when the user cleared or edited the filters the URL is the whole truth
// even when it ends up holding no filters at all
const filters =
useDefaults && Scrim.filtersAreDefault(filtersFromSearchParams)
? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters())
: filtersFromSearchParams;
const posts = (await ScrimPostRepository.findAllRelevant())
.filter(
(post) =>
(user && Scrim.isParticipating(post, user.id)) ||
Association.isVisible({
associations,
visibility: post.visibility,
contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
}),
)
.map((post) => ({
...post,
visibility: null,
isPrivate: !Association.isPublic({
visibility: post.visibility,
}),
}));
const cardUserIds = R.unique(
posts.flatMap((post) => [
...post.users.map((postUser) => postUser.id),
...post.requests.flatMap((request) =>
request.users.map((requestUser) => requestUser.id),
),
]),
);
return {
...(await UserCardRepository.findAllByUserIds({
userIds: cardUserIds,
})),
posts: dividePosts(posts, user?.id),
teams: user ? await TeamRepository.findAllByMemberUserId(user.id) : [],
filters,
canSaveAsDefault:
user != null &&
!R.isDeepEqual(
filters,
user.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters(),
),
};
},
);
/** The `/scrims/new` page data. */
export const getScrimsNewData = query(async () => {
const user = requireUser();
return {
teams: await TeamRepository.findAllByMemberUserId(user.id),
associations: await AssociationRepository.findByMemberUserId(user.id),
recentPickupRosters: await ScrimPickupRosterRepository.findAllOwnRecent(),
};
});
/**
* A booked scrim's page data, streamed live: map-by-map actions by either side
* wake the stream so both teams see reports/list changes without refreshing
* (what skalop's revalidation broadcasts did for the React app).
*/
export const getScrim = query.live(
v.object({ scrimPostId: id }),
async function* ({ scrimPostId }) {
yield await scrimSnapshot(scrimPostId);
for await (const _ of Events.subscribe(Events.scrimChannel(scrimPostId))) {
yield await scrimSnapshot(scrimPostId);
}
},
);
async function scrimSnapshot(scrimPostId: number) {
const user = requireUser();
const post = notFoundIfNullish(
await ScrimPostRepository.findById(scrimPostId),
);
if (!Scrim.isAccepted(post)) {
errorToast("Scrim is not accepted");
}
if (!Scrim.isParticipating(post, user.id) && !user.roles.includes("STAFF")) {
errorToast("Not a participant of this scrim");
}
// visiting the page addresses these notifications
await resolveNotifications({
userIds: [user.id],
type: "SCRIM_SCHEDULED",
meta: { id: post.id },
});
await resolveNotifications({
userIds: [user.id],
type: "SCRIM_STARTING_SOON",
meta: { id: post.id },
});
const participantIds = Scrim.participantIdsListFromAccepted(post);
const chatRoom = await ensureScrimChatRoom({
scrimPostId: post.id,
startsAt: Scrim.getStartTime(post),
});
return {
...(await UserCardRepository.findAllByUserIds({
userIds: participantIds,
include: { friendCode: true },
})),
post,
chatRoomId:
user.roles.includes("STAFF") || participantIds.includes(user.id)
? chatRoom.id
: undefined,
anyUserPrefersNoScreen:
await UserRepository.anyUserPrefersNoScreen(participantIds),
mapByMap: await resolveMapByMap({ post, user }),
};
}
async function resolveMapByMap({
post,
user,
}: {
post: NonNullable<Awaited<ReturnType<typeof ScrimPostRepository.findById>>>;
user: AuthenticatedUser;
}) {
const [mapLists, maps] = await Promise.all([
ScrimMapListRepository.findMapListsByScrimPostId(post.id),
ScrimMapRepository.findMapsByScrimPostId(post.id),
]);
const pool = mapLists.length > 0 ? ScrimMapByMap.unionPool(mapLists) : null;
const currentMap = maps.find((m) => m.reportedAt === null) ?? null;
const viewerSide = Scrim.sideOfUser(post, user.id);
const locked = Scrim.isTrackingLocked(maps, mapLists);
const ownList = viewerSide
? mapLists.find((l) => l.side === viewerSide)
: undefined;
return {
mapLists,
maps,
currentMap,
viewerSide,
locked,
pool: pool ? pool.stageModePairs : null,
ownPool: ownList?.mapList ?? null,
};
}
export const createScrimPost = command(
scrimsNewFormSchema,
async (data) => {
const user = requireUser();
if (data.from.mode === "PICKUP") {
const pickupUserError = await validatePickup(data.from.users, user.id);
if (pickupUserError) {
return { fieldErrors: { from: pickupUserError.error } };
}
}
const rangeEndDate = data.rangeEnd
? resolveRangeEndToDate(data.at, data.rangeEnd as RangeEnd)
: null;
const resolvedDivs = data.divs
? resolveDivs(data.divs as [LutiDiv | null, LutiDiv | null])
: null;
await ScrimPostRepository.insert({
startsAt: dateToDatabaseTimestamp(data.at),
rangeEndsAt: rangeEndDate
? dateToDatabaseTimestamp(rangeEndDate)
: null,
maxDiv: resolvedDivs?.[0] ? serializeLutiDiv(resolvedDivs[0]) : null,
minDiv: resolvedDivs?.[1] ? serializeLutiDiv(resolvedDivs[1]) : null,
text: data.postText,
managedByAnyone: data.managedByAnyone,
maps:
data.maps === "NO_PREFERENCE" || data.maps === "TOURNAMENT"
? null
: (data.maps as "SZ" | "RANKED" | "ALL"),
mapsTournamentId: data.mapsTournamentId,
isScheduledForFuture:
data.at >
// 10 minutes is an arbitrary threshold
add(new Date(), {
minutes: 10,
}),
visibility:
data.baseVisibility !== "PUBLIC"
? {
forAssociation: data.baseVisibility,
notFoundInstructions: data.notFoundVisibility.at
? [
{
at: dateToDatabaseTimestamp(
data.notFoundVisibility.at,
),
forAssociation:
data.notFoundVisibility.forAssociation !== "PUBLIC"
? data.notFoundVisibility.forAssociation
: null,
},
]
: undefined,
}
: null,
teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
users: (
await usersListForPost({ authorId: user.id, from: data.from })
).map((userId) => ({
userId,
isOwner: toDBBoolean(user.id === userId),
})),
});
if (data.from.mode === "PICKUP") {
await ScrimPickupRosterRepository.upsertOwn(data.from.users);
}
Events.publish(Events.scrimPostsChannel());
return null;
},
);
export const deleteScrimPost = command(
deletePostSchema,
async ({ scrimPostId }) => {
requireUser();
const post = await findPost({ postId: scrimPostId });
requirePermission(post, "DELETE_POST");
errorToastIfFalsy(
!Scrim.isAccepted(post),
"Can't delete an accepted scrim, cancel it instead",
);
await ScrimPostRepository.deleteById(post.id);
// requests to the deleted post can no longer be accepted
await resolveNotifications({
userIds: post.users.filter((u) => u.isOwner).map((u) => u.id),
type: "SCRIM_NEW_REQUEST",
meta: { scrimPostId: post.id },
});
await requested(getScrimPosts, 5).refreshAll();
},
);
export const newScrimRequest = command(newRequestSchema, async (data) => {
const user = requireUser();
const post = await findPost({ postId: data.scrimPostId });
if (post.visibility) {
const associations = await AssociationRepository.findByMemberUserId(
user.id,
);
const canSeePost = Association.isVisible({
associations,
visibility: post.visibility,
contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
});
errorToastIfFalsy(canSeePost, "Post not found");
}
if (data.from.mode === "PICKUP") {
const pickupUserError = await validatePickup(data.from.users, user.id);
if (pickupUserError) {
return { fieldErrors: { from: pickupUserError.error } };
}
}
if (post.rangeEndsAt && !data.at) {
return {
fieldErrors: { at: "Please select a time for the scrim" },
};
}
if (post.rangeEndsAt && data.at) {
const validTimeOptions = generateTimeOptions(
databaseTimestampToDate(post.startsAt),
databaseTimestampToDate(post.rangeEndsAt),
);
const requestTime = data.at.getTime();
if (!validTimeOptions.includes(requestTime)) {
return {
fieldErrors: {
at: "Selected time must be one of the available options",
},
};
}
}
try {
await ScrimPostRepository.insertRequest({
scrimPostId: data.scrimPostId,
teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
message: data.message,
startsAt:
post.rangeEndsAt && data.at ? dateToDatabaseTimestamp(data.at) : null,
users: (
await usersListForPost({ authorId: user.id, from: data.from })
).map((userId) => ({
userId,
isOwner: toDBBoolean(user.id === userId),
})),
});
} catch (thrown) {
if (thrown instanceof DuplicateEntryError) {
errorToast("Your team has already requested this scrim");
}
throw thrown;
}
notify({
userIds: post.users
.filter((postUser) => postUser.isOwner)
.map((postUser) => postUser.id),
notification: {
type: "SCRIM_NEW_REQUEST",
meta: {
fromUserId: user.id,
fromUsername: user.username,
scrimPostId: post.id,
},
},
});
Events.publish(Events.scrimPostsChannel());
await requested(getScrimPosts, 5).refreshAll();
return null;
});
export const acceptScrimRequest = command(
acceptRequestSchema,
async ({ scrimPostRequestId }) => {
const user = requireUser();
const { post, request } = await findRequest({
requestId: scrimPostRequestId,
});
requirePermission(post, "MANAGE_REQUESTS");
errorToastIfFalsy(!request.isAccepted, "Request is already accepted");
try {
await ScrimPostRepository.acceptRequest(scrimPostRequestId);
} catch (thrown) {
if (thrown instanceof ConcurrentModificationError) {
errorToast(
"Another request for this scrim was already accepted by someone else",
);
}
throw thrown;
}
// accepting one request settles the post, the rest can no longer be accepted
await resolveNotifications({
userIds: post.users.filter((u) => u.isOwner).map((u) => u.id),
type: "SCRIM_NEW_REQUEST",
meta: { scrimPostId: post.id },
});
const fullPost = await ScrimPostRepository.findById(post.id);
if (fullPost) {
await ensureScrimChatRoom({
scrimPostId: fullPost.id,
startsAt: Scrim.getStartTime(fullPost),
});
}
const postTeamName = Scrim.sideDisplayName(post);
const requestTeamName = Scrim.sideDisplayName(request);
notify({
userIds: post.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_SCHEDULED",
meta: { id: post.id, opponentTeamName: requestTeamName },
},
});
notify({
userIds: request.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_SCHEDULED",
meta: { id: post.id, opponentTeamName: postTeamName },
},
});
if (fullPost) {
try {
const bookedAt = databaseTimestampToDate(Scrim.getStartTime(fullPost));
const startTime = dateToDatabaseTimestamp(
sub(bookedAt, { hours: SCRIM.AUTO_CANCEL_WINDOW_HOURS }),
);
const endTime = dateToDatabaseTimestamp(
add(bookedAt, { hours: SCRIM.AUTO_CANCEL_WINDOW_HOURS }),
);
const { posts, requestIds } =
await ScrimPostRepository.findPendingOverlapsForUsers({
userIds: Scrim.participantIdsListFromAccepted(fullPost),
startTime,
endTime,
excludePostId: post.id,
});
for (const requestId of requestIds) {
await ScrimPostRepository.deleteRequest(requestId);
}
for (const removed of posts) {
await ScrimPostRepository.deleteById(removed.id);
notify({
userIds: removed.memberIds,
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_AUTO_DELETED",
meta: { at: removed.startsAt },
},
});
await resolveNotifications({
userIds: removed.memberIds,
type: "SCRIM_NEW_REQUEST",
meta: { scrimPostId: removed.id },
});
}
} catch (thrown) {
logger.error("Failed to auto-cancel overlapping scrims", thrown);
}
}
Events.publish(Events.scrimPostsChannel());
await requested(getScrimPosts, 5).refreshAll();
},
);
export const cancelScrimRequest = command(
cancelRequestSchema,
async ({ scrimPostRequestId }) => {
requireUser();
const { post, request } = await findRequest({
requestId: scrimPostRequestId,
});
requirePermission(request, "CANCEL");
errorToastIfFalsy(!request.isAccepted, "Can't cancel an accepted request");
await ScrimPostRepository.deleteRequest(scrimPostRequestId);
const requestOwner = request.users.find((u) => u.isOwner);
if (requestOwner) {
await resolveNotifications({
userIds: post.users.filter((u) => u.isOwner).map((u) => u.id),
type: "SCRIM_NEW_REQUEST",
meta: {
scrimPostId: post.id,
fromUserId: requestOwner.id,
},
});
}
Events.publish(Events.scrimPostsChannel());
await requested(getScrimPosts, 5).refreshAll();
},
);
export const persistScrimFilters = command(
persistScrimFiltersSchema,
async ({ filters }) => {
requireUser();
await UserRepository.updateOwnPreferences({
defaultScrimsFilters: filters,
});
await requested(getScrimPosts, 5).refreshAll();
},
);
export const cancelScrim = command(cancelScrimFormSchema, async (data) => {
const user = requireUser();
const post = notFoundIfNullish(
await ScrimPostRepository.findById(data.scrimPostId),
);
requirePermission(post, "MANAGE_TRACKING");
requirePermission(post, "CANCEL");
errorToastIfFalsy(Scrim.isAccepted(post), "Scrim is not accepted");
errorToastIfFalsy(!post.canceled, "Scrim is already canceled");
if (databaseTimestampToDate(Scrim.getStartTime(post)) < new Date()) {
errorToast("Cannot cancel a scrim that was already scheduled to start");
}
await ScrimPostRepository.cancelScrim(post.id, data.reason);
const acceptedRequest = post.requests.find((r) => r.isAccepted);
if (acceptedRequest) {
const postTeamName = Scrim.sideDisplayName(post);
const requestTeamName = Scrim.sideDisplayName(acceptedRequest);
notify({
userIds: post.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: requestTeamName },
},
});
notify({
userIds: acceptedRequest.users.map((m) => m.id),
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_CANCELED",
meta: { id: post.id, opponentTeamName: postTeamName },
},
});
// the canceled scrim is no longer happening
const participantIds = [
...post.users.map((m) => m.id),
...acceptedRequest.users.map((m) => m.id),
];
await resolveNotifications({
userIds: participantIds,
type: "SCRIM_SCHEDULED",
meta: { id: post.id },
});
await resolveNotifications({
userIds: participantIds,
type: "SCRIM_STARTING_SOON",
meta: { id: post.id },
});
}
await markScrimChatRoomInactive(post.id);
Events.publish(Events.scrimChannel(post.id));
Events.publish(Events.scrimPostsChannel());
await requested(getScrimPosts, 5).refreshAll();
return null;
});
export const submitMapList = command(submitMapListFormSchema, async (data) => {
const { post, viewerSide } = await requireMapByMapActor(data.scrimPostId);
if (data.source === "FROM_POST") {
errorToastIfFalsy(post.mapsTournament, "Post has no tournament to use");
}
const serializedPool =
data.source === "POOL"
? (parseMapPoolInput(data.serializedPool!)?.serialized ?? null)
: null;
errorToastIfFalsy(
data.source !== "POOL" || serializedPool,
"Invalid map pool",
);
const resolvedSource: "POOL" | "TOURNAMENT" =
data.source === "POOL" ? "POOL" : "TOURNAMENT";
await ScrimMapListRepository.submitMapListAndGenerateIfNeeded({
scrimPostId: post.id,
side: viewerSide,
source: resolvedSource,
tournamentId:
data.source === "FROM_POST"
? post.mapsTournament!.id
: (data.tournamentId ?? null),
serializedPool,
});
Events.publish(Events.scrimChannel(post.id));
return null;
});
export const removeMapList = command(
removeMapListSchema,
async ({ scrimPostId }) => {
const { post, viewerSide } = await requireMapByMapActor(scrimPostId);
await ScrimMapListRepository.deleteMapList(post.id, viewerSide);
Events.publish(Events.scrimChannel(post.id));
},
);
export const reportMap = command(reportMapSchema, async (data) => {
const { post, maps } = await requireMapByMapActor(data.scrimPostId);
const target = maps.find((m) => m.id === data.mapId);
errorToastIfFalsy(target, "Map not found");
errorToastIfFalsy(target!.reportedAt === null, "Map already reported");
await ScrimMapRepository.reportMapAndGenerateNext({
scrimPostId: post.id,
mapId: data.mapId,
winnerSide: data.winnerSide,
});
Events.publish(Events.scrimChannel(post.id));
});
export const undoMap = command(undoMapSchema, async ({ scrimPostId }) => {
const { post, maps } = await requireMapByMapActor(scrimPostId);
const latest = Scrim.lastReportedMap(maps);
errorToastIfFalsy(ScrimMapByMap.canUndo(latest, maps), "Nothing to undo");
await ScrimMapRepository.undoMostRecentMap(post.id);
Events.publish(Events.scrimChannel(post.id));
});
export const replayMap = command(replayMapSchema, async ({ scrimPostId }) => {
const { post, maps, user } = await requireMapByMapActor(scrimPostId);
const latest = Scrim.lastReportedMap(maps);
errorToastIfFalsy(latest, "No map to replay");
const currentMap = maps.find((m) => m.reportedAt === null);
errorToastIfFalsy(currentMap, "No current map to replace");
await ScrimMapRepository.replaceCurrentMap({
scrimPostId: post.id,
mode: latest!.mode,
stageId: latest!.stageId,
});
await broadcastMapChange({ post, type: "MAP_REPLAYED", user });
Events.publish(Events.scrimChannel(post.id));
});
export const pickMap = command(pickMapFormSchema, async (data) => {
const { post, maps, user } = await requireMapByMapActor(data.scrimPostId);
const currentMap = maps.find((m) => m.reportedAt === null);
errorToastIfFalsy(currentMap, "No current map to replace");
await ScrimMapRepository.replaceCurrentMap({
scrimPostId: post.id,
mode: data.mode,
stageId: data.stageId,
});
await broadcastMapChange({ post, type: "MAP_PICKED", user });
Events.publish(Events.scrimChannel(post.id));
return null;
});
/** Loads the post + map-by-map context, gating on tracking permission & lock. */
async function requireMapByMapActor(scrimPostId: number) {
const user = requireUser();
const post = notFoundIfNullish(
await ScrimPostRepository.findById(scrimPostId),
);
requirePermission(post, "MANAGE_TRACKING");
const viewerSide = Scrim.sideOfUser(post, user.id);
const [maps, mapLists] = await Promise.all([
ScrimMapRepository.findMapsByScrimPostId(post.id),
ScrimMapListRepository.findMapListsByScrimPostId(post.id),
]);
if (Scrim.isTrackingLocked(maps, mapLists)) {
errorToast("Tracking is locked");
}
return { user, post, viewerSide: viewerSide!, maps, mapLists };
}
async function broadcastMapChange({
post,
type,
user,
}: {
post: { id: number };
type: "MAP_REPLAYED" | "MAP_PICKED";
user: AuthenticatedUser;
}) {
const room = await ensureScrimChatRoom({
scrimPostId: post.id,
startsAt: Scrim.getStartTime(
notFoundIfNullish(await ScrimPostRepository.findById(post.id)),
),
});
await sendSystemMessage({
chatRoomId: room.id,
type,
context: { name: user.username },
});
}
async function findPost({ postId }: { postId: number }) {
const posts = await ScrimPostRepository.findAllRelevant();
const post = posts.find((relevantPost) => relevantPost.id === postId);
errorToastIfFalsy(post, "Post not found");
return post!;
}
async function findRequest({ requestId }: { requestId: number }) {
const posts = await ScrimPostRepository.findAllRelevant();
const post = posts.find((relevantPost) =>
relevantPost.requests.some((request) => request.id === requestId),
);
const request = post?.requests.find(
(postRequest) => postRequest.id === requestId,
);
errorToastIfFalsy(post && request, "Request not found");
return { post: post!, request: request! };
}
const usersListForPost = async ({
from,
authorId,
}: {
from: v.InferOutput<typeof fromSchema>;
authorId: number;
}) => {
if (from.mode === "PICKUP") {
return [authorId, ...from.users];
}
const teamId = from.teamId;
const team = (await TeamRepository.findAllByMemberUserId(authorId)).find(
(memberTeam) => memberTeam.id === teamId,
);
errorToastIfFalsy(team, "User is not a member of this team");
const filteredMembers = team!.members.filter(
(member) => getMemberRoleType(member) !== "OTHER",
);
// handle case when all users are from excluded roles
const result = (
filteredMembers.length >= SCRIM.MIN_MEMBERS_PER_TEAM
? filteredMembers
: team!.members
).map((member) => member.id);
if (result.length < SCRIM.MIN_MEMBERS_PER_TEAM) {
errorToast("Your team does not have enough members (4) to scrim");
}
// ensure author is included in the list even if they match the ignore condition
return result.includes(authorId) ? result : [authorId, ...result];
};
/** Validates that a pickup roster can be put together by the author. */
async function validatePickup(userIds: number[], authorId: number) {
if (userIds.includes(authorId)) {
return { error: "Don't add yourself to the pickup member list" };
}
const friendsError = await validatePickupFriends(userIds, authorId);
if (friendsError) {
return friendsError;
}
const unbannedError = await validatePickupAllUnbanned(userIds);
if (unbannedError) {
return unbannedError;
}
return null;
}
async function validatePickupFriends(userIds: number[], authorId: number) {
const unconsentingUsers: string[] = [];
const friendsData = await SQGroupRepository.findFriendsAndTeammates(authorId);
for (const userId of userIds) {
const user = await UserRepository.findLeanById(userId);
invariant(user, "User not found");
if (
user.preferences?.disallowScrimPickupsFromUntrusted &&
!friendsData.friends.some((friend) => friend.id === userId)
) {
unconsentingUsers.push(user.username);
}
}
return unconsentingUsers.length === 0
? null
: {
error: `Following users don't allow non-friends to add: ${unconsentingUsers.join(", ")}. Ask them to add you as a friend.`,
};
}
async function validatePickupAllUnbanned(userIds: number[]) {
const bannedFlags = await Promise.all(
userIds.map((userId) => userIsBanned(userId)),
);
return bannedFlags.every((banned) => !banned)
? null
: {
error: "Pickup includes banned users.",
};
}
type RangeEnd = (typeof RANGE_END_OPTIONS)[number];
function resolveRangeEndToDate(startDate: Date, rangeEnd: RangeEnd): Date {
switch (rangeEnd) {
case "+30min":
return add(startDate, { minutes: 30 });
case "+1hour":
return add(startDate, { hours: 1 });
case "+1.5hours":
return add(startDate, { hours: 1, minutes: 30 });
case "+2hours":
return add(startDate, { hours: 2 });
case "+2.5hours":
return add(startDate, { hours: 2, minutes: 30 });
case "+3hours":
return add(startDate, { hours: 3 });
default: {
assertUnreachable(rangeEnd);
}
}
}
function resolveDivs(
divs: [LutiDiv | null, LutiDiv | null],
): [LutiDiv | null, LutiDiv | null] {
const [max, min] = divs;
if (!max || !min) return divs;
const maxIndex = LUTI_DIVS.indexOf(max);
const minIndex = LUTI_DIVS.indexOf(min);
if (minIndex < maxIndex) {
return [min, max];
}
return divs;
}
export type ScrimPageData = Awaited<ReturnType<typeof getScrim>>;

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import Avatar from "#lib/components/Avatar.svelte";
import { m } from "#lib/paraglide/messages.js";
import type { CommonUser } from "#lib/server/kysely.ts";
const MAX_VISIBLE_AVATARS = 5;
interface Props {
mutualFriends: Array<CommonUser>;
}
let { mutualFriends }: Props = $props();
const visibleFriends = $derived(mutualFriends.slice(0, MAX_VISIBLE_AVATARS));
const overflowCount = $derived(mutualFriends.length - MAX_VISIBLE_AVATARS);
</script>
<!--
@component
Static mutual friends avatar stack with a count, as shown on the user card.
xxx: React's `MutualFriends` also has an interactive variant (`withoutPopover={false}`, used
outside the card) where the stack is a popover trigger listing each friend as a `UserLink`;
that variant is not ported yet.
-->
{#if mutualFriends.length > 0}
<div class="trigger">
<div class="avatarStack">
{#each visibleFriends as friend (friend.id)}
<Avatar user={friend} size="xxs" class="stackedAvatar" />
{/each}
</div>
{#if overflowCount > 0}
<span class="overflow">+{overflowCount}</span>
{/if}
<span>
{mutualFriends.length === 1
? m.user_mutualFriends_count_one({ count: mutualFriends.length })
: m.user_mutualFriends_count_other({ count: mutualFriends.length })}
</span>
</div>
{/if}
<style>
.trigger {
display: flex;
align-items: center;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
span {
color: var(--color-text-high);
}
}
.avatarStack {
display: flex;
align-items: center;
& :global(.stackedAvatar) {
border-radius: 50%;
border: 2px solid var(--color-bg);
&:not(:first-child) {
margin-inline-start: -8px;
}
}
}
.overflow {
font-size: var(--font-xs);
margin-inline: var(--s-1);
}
</style>

View File

@@ -0,0 +1,722 @@
<script lang="ts">
import {
BadgeCheck,
Flag,
Megaphone,
NotebookPen,
NotebookText,
Pencil,
Trash2,
UserPlus,
UserRoundCheck,
VenetianMask,
} from "@lucide/svelte";
import { Button, LinkButton, Popover } from "@sendou/components";
import type { BrandId } from "@sendou/in-game-lists/types";
import { assertUnreachable } from "@sendou/utils/types";
import type { Snippet } from "svelte";
import Avatar from "#lib/components/Avatar.svelte";
import Image from "#lib/components/Image.svelte";
import LocaleTime from "#lib/components/LocaleTime.svelte";
import NoteAvatar from "#lib/components/NoteAvatar.svelte";
import Placement from "#lib/components/Placement.svelte";
import TierImage from "#lib/components/TierImage.svelte";
import type { XRankPlacementRegion } from "#lib/db/tables-json.ts";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { getUserCardContext } from "#lib/features/user-page/user-card-context.ts";
import type {
UserCardData,
UserCardFriendship,
UserCardStat,
} from "#lib/features/user-page/user-card-types.ts";
import { getUserCardFriendship } from "#lib/features/user-page/user-card.remote.ts";
import { m } from "#lib/paraglide/messages.js";
import {
brandImageUrl,
impersonateUrl,
lfgPostPage,
navIconUrl,
stageBannerImageUrl,
userCardEditPage,
userPage,
} from "#lib/utils/urls.ts";
import { dev } from "$app/env";
import { page } from "$app/state";
import MutualFriends from "./MutualFriends.svelte";
const TENTATEK_BRAND_ID: BrandId = "B10";
const STAT_ORDER: Record<UserCardStat["type"], number> = {
XP: 0,
SEASON: 1,
PLUS: 2,
DIV: 3,
};
interface Props {
userId?: number;
data?: UserCardData;
/** Fetch and show the mutual friends row. Off by default. */
withMutualFriends?: boolean;
children: Snippet;
}
let {
userId,
data: dataProp,
withMutualFriends = false,
children,
}: Props = $props();
const context = getUserCardContext();
const data = $derived(
dataProp ??
(typeof userId === "number"
? context?.userCards()?.get(userId)
: undefined),
);
const isOwnCard = $derived(loggedInUser()?.id === data?.id);
const returnTo = $derived(`${page.url.pathname}${page.url.search}`);
const stats = $derived(
data
? data.stats.toSorted((a, b) => STAT_ORDER[a.type] - STAT_ORDER[b.type])
: [],
);
let isOpen = $state(false);
// an existing note shows in-place (toggled); with no note the button opens the add modal directly
let isNoteOpen = $state(false);
let friendship = $state<UserCardFriendship | undefined>();
let friendshipRequested = false;
const showNoteView = $derived(isNoteOpen && data?.privateNote != null);
function handleOpenChange(nextIsOpen: boolean) {
isOpen = nextIsOpen;
if (!nextIsOpen) return;
if (friendshipRequested) return;
if (isOwnCard) return;
const targetUserId = data?.id;
if (typeof targetUserId !== "number") return;
friendshipRequested = true;
getUserCardFriendship({ userId: targetUserId, withMutualFriends })
.then((result) => {
friendship = result;
})
.catch(() => {
// on failure the card just renders without the viewer-relative rows
});
}
function onNoteButtonPress() {
if (!data) return;
if (data.privateNote === null) {
// xxx: AddPrivateNoteDialog is not ported yet (depends on the unported SendouForm/dialog system)
return;
}
isNoteOpen = !isNoteOpen;
}
function onEditNotePress() {
// xxx: AddPrivateNoteDialog is not ported yet (depends on the unported SendouForm/dialog system)
}
function onDeleteNotePress() {
// xxx: delete-note confirm dialog (FormWithConfirm) + the private note write path are not ported yet
}
function onReportPress() {
// xxx: ReportUserDialog (user-report feature) is not ported yet
}
function onFriendRequestPress() {
// xxx: friend request send/accept (the /friends write path + success toasts) is not ported yet
}
function bannerStyle(banner: UserCardData["banner"]) {
switch (banner.type) {
case "STAGE":
return `background-image: url(${stageBannerImageUrl(banner.stageId)})`;
case "URL":
return `background-image: url(${banner.url})`;
case "COLOR":
return `background-color: ${banner.hexCode}`;
default:
return assertUnreachable(banner);
}
}
function customThemeStyle(customTheme: UserCardData["customTheme"]) {
if (!customTheme) return undefined;
return Object.entries(customTheme)
.filter(
([key, value]) =>
value !== null && !key.includes("--_size") && !key.includes("--_border"),
)
.map(([key, value]) => `${key}: ${value}`)
.join("; ");
}
</script>
<!--
@component
Click-to-open trigger that shows a popover with the user's card. Card data is resolved by `userId`
from the {@link UserCardContext} (set by pages whose queries spread `{ userCards }` from
`UserCardRepository.findAllByUserIds`); pass `data` directly to bypass the lookup. When no card data
exists for the user, the `children` are rendered plain without a trigger.
Viewer-relative friendship data (`isFriend`) is lazy-loaded via the `getUserCardFriendship` remote
query the first time the card opens. Mutual friends are only fetched and shown when
`withMutualFriends` is set (e.g. the SendouQ looking page); other views (e.g. match pages) skip
both the extra query and the row.
-->
{#snippet divImage(region: XRankPlacementRegion)}
{#if region === "WEST"}
<Image
path={brandImageUrl(TENTATEK_BRAND_ID)}
alt={m.common_divisions_WEST()}
width={18}
height={18}
/>
{/if}
{/snippet}
{#snippet statView(stat: UserCardStat)}
{#if stat.type === "XP"}
{@const unverified = stat.values.find((value) => !value.isVerified)}
{@const verified = stat.values.find((value) => value.isVerified)}
{@const primary = unverified ?? verified}
{@const secondary = unverified ? verified : undefined}
<span class="stat xpStat">
{#if primary}
<span class="xpPrimary">
<span class="xpPrimaryIcons"
>{#if primary.isVerified}<BadgeCheck
class={primary.region === "WEST"
? "xpVerifiedIconSmall"
: "xpVerifiedIconLarge"}
/>{/if}{@render divImage(primary.region)}</span
>
{primary.points}{m.user_card_xp()}
</span>
{/if}
{#if secondary}
<span class="xpVerified">
<BadgeCheck class="xpVerifiedIconSmall" />
{@render divImage(secondary.region)}
{secondary.points}{m.user_card_xp()}
</span>
{/if}
</span>
{:else if stat.type === "DIV"}
<span class="stat">Div {stat.value}</span>
{:else if stat.type === "PLUS"}
<span class="stat plusStat">
<Image path={navIconUrl("plus")} alt="+" size={24} />
{stat.value}
</span>
{:else if stat.type === "SEASON"}
<span class="seasonStat">
<TierImage tier={stat.value} width={32} />
{#if typeof stat.top === "number"}
<span class="seasonTop">
<Placement
placement={stat.top}
size={14}
showAsSuperscript={false}
textOnly
/>
</span>
{/if}
</span>
{/if}
{/snippet}
{#snippet friendRequestButton(friendshipData: UserCardFriendship)}
{#if friendshipData.incomingFriendRequestId !== null}
<Button
size="miniscule"
shape="circle"
aria-label="Accept friend request"
onclick={onFriendRequestPress}
>
{#snippet icon()}<UserPlus />{/snippet}
</Button>
{:else if friendshipData.sentFriendRequest}
<Button
size="miniscule"
shape="circle"
disabled
aria-label={m.user_card_friendRequestPending()}
>
{#snippet icon()}<UserRoundCheck />{/snippet}
</Button>
{:else}
<Button
size="miniscule"
shape="circle"
aria-label={m.user_card_sendFriendRequest()}
onclick={onFriendRequestPress}
>
{#snippet icon()}<UserPlus />{/snippet}
</Button>
{/if}
{/snippet}
{#snippet noteView(note: NonNullable<UserCardData["privateNote"]>)}
<div class="noteView">
<div class="noteHeaderGroup">
<span class="noteHeader">{m.user_card_privateNote()}</span>
<LocaleTime
date={note.updatedAt}
options={{ day: "numeric", month: "numeric", year: "numeric" }}
class="noteDate"
inline
/>
</div>
{#if note.text}
<p class="noteText">{note.text}</p>
{/if}
<div class="noteViewActions">
<Button variant="minimal" size="miniscule" onclick={onEditNotePress}>
{#snippet icon()}<Pencil />{/snippet}
{m.common_actions_edit()}
</Button>
<Button
variant="minimal-destructive"
size="miniscule"
onclick={onDeleteNotePress}
>
{#snippet icon()}<Trash2 />{/snippet}
{m.common_actions_delete()}
</Button>
</div>
</div>
{/snippet}
{#if !data}
{@render children()}
{:else}
<Popover
{isOpen}
onOpenChange={handleOpenChange}
popoverClass="userCardPopover"
>
{#snippet trigger(triggerProps)}
<button type="button" class="trigger" {...triggerProps}>
{@render children()}
</button>
{/snippet}
<div
class="card"
style={customThemeStyle(data.customTheme)}
data-custom-theme={data.customTheme ? true : undefined}
>
<div
class="banner"
style={bannerStyle(data.banner)}
data-testid="user-card-banner"
></div>
{#if data.freeAgentPostId !== null}
<LinkButton
href={lfgPostPage(data.freeAgentPostId)}
size="miniscule"
class="freeAgentBadge"
>
{#snippet icon()}<Megaphone />{/snippet}
{m.user_card_freeAgent()}
</LinkButton>
{/if}
<div class="iconButtons">
{#if isOwnCard}
<LinkButton href={userCardEditPage({ returnTo })} size="miniscule">
{#snippet icon()}<Pencil />{/snippet}
{m.common_actions_edit()}
</LinkButton>
{:else}
{#if dev}
<form method="post" action={impersonateUrl(data.id)}>
<input type="hidden" name="returnTo" value={returnTo} />
<Button
type="submit"
size="miniscule"
shape="circle"
aria-label="Impersonate user"
>
{#snippet icon()}<VenetianMask />{/snippet}
</Button>
</form>
{/if}
{#if friendship && !friendship.isFriend}
{@render friendRequestButton(friendship)}
{/if}
<Button
size="miniscule"
shape="circle"
onclick={onNoteButtonPress}
aria-label={m.user_card_editPrivateNote()}
>
{#snippet icon()}
{#if data.privateNote !== null}
<NotebookText />
{:else}
<NotebookPen />
{/if}
{/snippet}
</Button>
{#if loggedInUser()}
<Button
size="miniscule"
shape="circle"
onclick={onReportPress}
aria-label="Report user"
testId="report-user-button"
>
{#snippet icon()}<Flag />{/snippet}
</Button>
{/if}
{/if}
</div>
<div class="identity">
<NoteAvatar
sentiment={data.privateNote?.sentiment}
onclick={isOwnCard ? undefined : onNoteButtonPress}
>
<Avatar user={data} size="md" class="avatar" />
</NoteAvatar>
<div class="nameGroup">
<h2 class="username">{data.username}</h2>
{#if data.customUrl}
<div class="subtitle">{data.customUrl}</div>
{/if}
{#if data.friendCode}
<span class="friendCode">SW-{data.friendCode}</span>
{:else}
<!-- reserve space -->
<span class="friendCode">{"\u{200b}"}</span>
{/if}
</div>
</div>
{#if showNoteView && data.privateNote}
{@render noteView(data.privateNote)}
{:else}
{#if stats.length > 0}
<div class="stats">
{#each stats as stat, i (stat.type)}
{#if i > 0}<span class="statDivider"></span>{/if}
{@render statView(stat)}
{/each}
</div>
{/if}
{#if !isOwnCard && withMutualFriends}
<div class="mutualFriends">
{#if friendship !== undefined}
{#if friendship.mutualFriends.length === 0}
<span class="noMutualFriends"
>{m.user_card_noMutualFriends()}</span
>
{:else}
<MutualFriends mutualFriends={friendship.mutualFriends} />
{/if}
{/if}
</div>
{/if}
{#if data.shortBio}
<p class="bio">{data.shortBio}</p>
{/if}
<LinkButton
href={userPage(data)}
variant="outlined"
size="small"
class="viewUserPage"
>
{m.user_card_viewUserPage()}
</LinkButton>
{/if}
</div>
</Popover>
{/if}
<style>
/* class is repeated to out-specify the base `.content` styles scoped inside Popover.svelte */
:global(.userCardPopover.userCardPopover.userCardPopover) {
border: none;
background: none;
padding: 0;
max-width: none;
white-space: normal;
font-size: var(--font-sm);
font-weight: var(--weight-body);
}
/* beside the trigger there is no room for the card on a narrow viewport, so it is placed
vertically there (the base block-end position) and to the right on wider viewports */
@media (min-width: 600px) {
:global(.userCardPopover.userCardPopover.userCardPopover) {
position-area: inline-end;
position-try-fallbacks: flip-inline, block-end;
align-self: anchor-center;
justify-self: normal;
}
}
.trigger {
display: inline-flex;
width: fit-content;
padding: 0;
margin: 0;
border: none;
background: none;
color: inherit;
font: inherit;
text-align: inherit;
cursor: pointer;
}
.card {
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-5);
width: 18rem;
max-width: calc(100vw - var(--s-4));
padding: 0 var(--s-4) var(--s-4);
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-box);
overflow: hidden;
& :global(.freeAgentBadge) {
position: absolute;
top: var(--s-2);
inset-inline-start: var(--s-2);
}
& :global(.viewUserPage) {
margin-top: var(--s-1);
align-self: center;
}
& :global(.xpVerifiedIconSmall) {
width: var(--s-4);
height: var(--s-4);
color: var(--color-success);
}
& :global(.xpVerifiedIconLarge) {
width: var(--s-5);
height: var(--s-5);
color: var(--color-success);
}
}
.banner {
height: 6rem;
margin-inline: calc(-1 * var(--s-4));
background-size: cover;
background-position: center;
background-color: var(--color-bg-higher);
}
.iconButtons {
position: absolute;
top: var(--s-2);
inset-inline-end: var(--s-2);
display: flex;
gap: var(--s-2);
}
.identity {
display: flex;
align-items: flex-end;
gap: var(--s-2-5);
margin-top: calc(-1 * (var(--s-8)));
position: relative;
& :global(.avatar) {
border: 3px solid var(--color-bg);
margin-block-end: -3px;
}
}
.nameGroup {
display: flex;
flex-direction: column;
padding-bottom: var(--s-6);
position: absolute;
top: 18px;
inset-inline-start: 92px;
}
.username {
font-size: var(--font-lg);
font-weight: var(--weight-bold);
line-height: 1.1;
max-width: 170px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.subtitle {
max-width: 170px;
font-size: var(--font-xs);
color: var(--color-text-high);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.friendCode {
font-size: var(--font-xs);
color: var(--color-text-high);
}
.stats {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2-5);
font-weight: var(--weight-bold);
}
.stat {
white-space: nowrap;
flex-shrink: 0;
}
.plusStat {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
.xpStat {
display: inline-flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.1;
}
.xpPrimary {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
.xpPrimaryIcons {
display: inline-flex;
flex-direction: column;
align-items: center;
&:empty {
display: none;
}
}
.xpVerified {
display: inline-flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.seasonStat {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.seasonTop {
position: absolute;
bottom: calc(-1 * var(--s-1));
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
padding: 0 var(--s-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
background-color: var(--color-bg);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
line-height: 1;
}
.statDivider {
width: 2px;
min-width: 2px;
align-self: stretch;
background-color: var(--color-border);
}
.mutualFriends {
display: flex;
align-items: center;
min-height: var(--field-size-sm);
}
.noMutualFriends {
margin-inline: auto;
font-style: italic;
font-size: var(--font-xs);
color: var(--color-text-high);
}
.bio {
font-size: var(--font-xs);
color: var(--color-text);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.noteView {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.noteHeaderGroup {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
& :global(.noteDate) {
font-size: var(--font-2xs);
color: var(--color-text-high);
}
}
.noteHeader {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
color: var(--color-text);
}
.noteText {
font-size: var(--font-xs);
color: var(--color-text);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.noteViewActions {
margin-block-start: var(--s-2);
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-4);
}
</style>

View File

@@ -0,0 +1,24 @@
import { createContext } from "svelte";
import type { UserCardData } from "./user-card-types.ts";
export interface UserCardContext {
/** Card data by user id, as returned by `UserCardRepository.findAllByUserIds`. */
userCards: () => Map<number, UserCardData> | undefined;
}
const [getOrThrow, set] = createContext<UserCardContext>();
/** Provides `UserCard` data for the subtree; pages whose queries include `userCards` set this. */
export const setUserCardContext = set;
/**
* The nearest provided {@link UserCardContext}, or `undefined` when no ancestor set one —
* `UserCard` then renders its children plain without the popover.
*/
export function getUserCardContext(): UserCardContext | undefined {
try {
return getOrThrow();
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,56 @@
import * as v from "valibot";
import { getUser } from "#lib/features/auth/user.server.ts";
import * as FriendRepository from "#lib/features/friends/FriendRepository.server.ts";
import { id } from "#lib/utils/schemas.ts";
import { query } from "$app/server";
import type { UserCardFriendship } from "./user-card-types.ts";
/**
* Viewer-relative friendship data for a single user, lazy-loaded by the `UserCard`
* popover when it opens (keeps `isFriend` + `mutualFriends` out of the batched card
* query). Resolves to empty values when there is no logged-in viewer. Mutual friends
* are only resolved when the card opts in via `withMutualFriends` (some views, e.g.
* match pages, don't surface them), so the extra query is otherwise skipped.
*/
export const getUserCardFriendship = query(
v.object({ userId: id, withMutualFriends: v.boolean() }),
async ({ userId, withMutualFriends }): Promise<UserCardFriendship> => {
const viewer = getUser();
if (!viewer) {
return {
isFriend: false,
sentFriendRequest: false,
incomingFriendRequestId: null,
mutualFriends: [],
};
}
const [friendship, pendingRequest, mutualFriends] = await Promise.all([
FriendRepository.findFriendship({
userOneId: viewer.id,
userTwoId: userId,
}),
FriendRepository.findFriendRequestBetween({
senderId: viewer.id,
receiverId: userId,
}),
withMutualFriends
? FriendRepository.findMutualFriends({
loggedInUserId: viewer.id,
targetUserId: userId,
})
: [],
]);
return {
isFriend: Boolean(friendship),
sentFriendRequest: pendingRequest?.senderId === viewer.id,
incomingFriendRequestId:
pendingRequest && pendingRequest.senderId !== viewer.id
? pendingRequest.id
: null,
mutualFriends,
};
},
);

View File

@@ -10,8 +10,11 @@ const messages = m as unknown as Record<
* 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;
export function dynamicMessage(
key: string,
inputs?: Record<string, unknown>,
): string {
return messages[key]?.(inputs) ?? key;
}
/** Localized name of a main weapon. */
@@ -34,6 +37,11 @@ export function modeLongName(mode: string) {
return dynamicMessage(`game_misc_MODE_LONG_${mode}`);
}
/** Localized short name of a mode (e.g. "SZ"). */
export function modeShortName(mode: string) {
return dynamicMessage(`game_misc_MODE_SHORT_${mode}`);
}
/** Localized stage name. */
export function stageName(stageId: number) {
return dynamicMessage(`game_misc_STAGE_${stageId}`);

View File

@@ -0,0 +1,94 @@
import type { UserPreferences } from "#lib/db/tables-json.ts";
import { loggedInUser } from "#lib/features/auth/user-state.ts";
import { getLocale } from "#lib/paraglide/runtime.js";
import { databaseTimestampToDate } from "#lib/utils/dates.ts";
import { browser } from "$app/env";
/**
* Zero-width space rendered (hidden via `invisible`) during SSR so the element
* reserves exactly one normal text line, avoiding layout shift on hydration
* without an empty box's baseline quirks.
*/
const SSR_PLACEHOLDER = "​";
const SSR_FORMATTER = {
format: (_date: Date | number) => SSR_PLACEHOLDER,
formatRange: (_from: Date | number, _to: Date | number) => SSR_PLACEHOLDER,
};
/**
* Resolves the language and hour cycle to use when formatting dates and times
* for the current user. Prefers a browser language sharing a base tag with the
* active site language (e.g. `en-GB` over site `en`) for regional formatting.
* `isLoaded` is `false` during SSR; gate locale-dependent output on it to
* avoid hydration flicker.
*/
export function userIntlPreference() {
const siteLanguage = getLocale();
const browserLanguages = browser ? navigator.languages : [];
// does the user want to use their browser language even if the site is in another language?
const language =
browserLanguages.find((lang) => compareLanguages(lang, siteLanguage)) ??
siteLanguage;
return {
language,
hourCycle: resolveHourCycle(loggedInUser()?.preferences?.clockFormat),
isLoaded: browser,
};
}
/**
* SSR-safe wrapper around `Intl.DateTimeFormat`.
*
* Uses the user's locale and hour cycle preferences via
* {@link userIntlPreference}. During SSR the returned formatter's methods
* return a zero-width space placeholder so the element reserves one text line
* until the client-formatted value replaces it on hydration.
*
* Inputs accept either a `Date` or a database timestamp (`number`); numbers
* are converted via `databaseTimestampToDate`.
*/
export function dateTimeFormat(options: Intl.DateTimeFormatOptions) {
const { language, hourCycle, isLoaded } = userIntlPreference();
const formatter = new Intl.DateTimeFormat(language, {
...options,
...(options.hour && hourCycle ? { hourCycle } : {}),
});
const realFormatter = {
format: (date: Date | number) => {
return formatter.format(
typeof date === "number" ? databaseTimestampToDate(date) : date,
);
},
formatRange: (from: Date | number, to: Date | number) => {
return formatter.formatRange(
typeof from === "number" ? databaseTimestampToDate(from) : from,
typeof to === "number" ? databaseTimestampToDate(to) : to,
);
},
};
return {
formatter: isLoaded ? realFormatter : SSR_FORMATTER,
isLoaded,
};
}
function resolveHourCycle(
clockFormat: UserPreferences["clockFormat"],
): "h12" | "h23" | undefined {
if (clockFormat === "12h") return "h12";
if (clockFormat === "24h") return "h23";
return undefined;
}
function compareLanguages(a: string, b: string) {
const baseA = a.split("-")[0];
const baseB = b.split("-")[0];
return baseA.toUpperCase() === baseB.toUpperCase();
}

View File

@@ -1,6 +1,7 @@
import { error } from "@sveltejs/kit";
import { getUser } from "#lib/features/auth/user.server.ts";
import type { Role } from "./types.ts";
import { getUser, requireUser } from "#lib/features/auth/user.server.ts";
import type { EntityWithPermissions, Role } from "./types.ts";
import { hasPermission } from "./utils.ts";
/** Responds with 403 unless the logged in user has the given role. */
export function requireRole(role: Role) {
@@ -10,3 +11,17 @@ export function requireRole(role: Role) {
error(403, "Forbidden");
}
}
/** Responds with 403 unless the logged in user has the given permission on the entity. */
export function requirePermission<
T extends EntityWithPermissions,
K extends keyof T["permissions"],
>(obj: T, permission: K) {
const user = requireUser();
if (hasPermission(obj, permission, user)) {
return;
}
error(403, "Forbidden");
}

View File

@@ -0,0 +1,30 @@
import { error } from "@sveltejs/kit";
/**
* Rejects the remote function with a user-readable message.
*
* xxx: surface these as error toasts on the client (toast infra arrives with
* the layout polish) — for now callers see the failed mutation's message.
*/
export function errorToast(message: string): never {
error(400, message);
}
/** Like `errorToast` but only when the condition is falsy. */
export function errorToastIfFalsy(
condition: unknown,
message: string,
): asserts condition {
if (!condition) {
errorToast(message);
}
}
/** Responds with 404 when the value is null/undefined, narrowing it otherwise. */
export function notFoundIfNullish<T>(value: T): NonNullable<T> {
if (value === null || value === undefined) {
error(404, "Not found");
}
return value;
}

View File

@@ -0,0 +1,5 @@
import type { DBBoolean } from "#lib/server/db/tables.ts";
export function toDBBoolean(value: boolean): DBBoolean {
return value ? 1 : 0;
}

View File

@@ -30,6 +30,8 @@ export const twitchUrl = (accountName: string) =>
export const tournamentInfoPage = (tournamentId: number) =>
`/to/${tournamentId}/info`;
export const tournamentPage = (tournamentId: number) => `/to/${tournamentId}`;
export const tournamentRegisterPage = (tournamentId: number) =>
`/to/${tournamentId}/register`;
export const tournamentStreamsPage = (tournamentId: number) =>
`/to/${tournamentId}/streams`;
export const SENDOUQ_STREAMS_PAGE = "/q/streams";
@@ -79,6 +81,21 @@ export const isCustomUrl = (value: string) => {
export const teamPage = (customUrl: string) => `/t/${customUrl}`;
export const scrimsPage = () => "/scrims";
export const scrimPage = (id: number) => `${scrimsPage()}/${id}`;
export const newScrimPostPage = () => `${scrimsPage()}/new`;
export const associationsPage = () => "/associations";
export const LFG_PAGE = "/lfg";
export const lfgPostPage = (postId: number) =>
`${LFG_PAGE}?post=${postId}#${postId}`;
export const USER_CARD_EDIT_PAGE = "/user-card/edit";
export const userCardEditPage = (args?: { returnTo?: string }) =>
args?.returnTo
? `${USER_CARD_EDIT_PAGE}?returnTo=${encodeURIComponent(args.returnTo)}`
: USER_CARD_EDIT_PAGE;
export const topSearchPage = () => "/xsearch";
export const topSearchPlayerPage = (playerId: number) =>
`${topSearchPage()}/player/${playerId}`;
@@ -101,8 +118,12 @@ 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 brandImageUrl = (brand: string) =>
`${STATIC_ASSETS_URL}/img/brands/${brand}`;
export const stageImageUrl = (stageId: number) =>
`${STATIC_ASSETS_URL}/img/stages/${stageId}`;
export const stageBannerImageUrl = (stageId: number) =>
`${STATIC_ASSETS_URL}/img/stage-banners/${stageId}.avif`;
export const modeImageUrl = (mode: string) =>
`${STATIC_ASSETS_URL}/img/modes/${mode}`;
export const tierImageUrl = (tier: string) =>

8
apps/web/src/params.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineParams } from "@sveltejs/kit/params";
export const params = defineParams({
integer: (param) => {
if (!/^\d+$/.test(param)) return;
return Number(param);
},
});

View File

@@ -1,9 +1,12 @@
import { error, json } from "@sveltejs/kit";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "#lib/features/admin/dev-controls.server.ts";
import { deleteInactiveChatRooms } from "#lib/features/chat/chat.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>>();
const routines = new Map<string, () => Promise<void>>([
["deleteInactiveChatRooms", deleteInactiveChatRooms],
]);
export const POST: RequestHandler = async ({ request }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import MetaTags from "#lib/components/MetaTags.svelte";
import ScrimsPage from "#lib/features/scrims/components/ScrimsPage.svelte";
</script>
<MetaTags
title="Scrims"
ogTitle="Splatoon scrim finder"
description="Schedule scrims against competitive teams. Make your own post or browse available scrims."
/>
<ScrimsPage />

View File

@@ -0,0 +1,14 @@
import { navIconUrl, scrimsPage } from "#lib/utils/urls.ts";
import type { PageLoad } from "./$types";
export const load: PageLoad = () => {
return {
breadcrumbs: [
{
type: "IMAGE" as const,
imgPath: navIconUrl("scrims"),
href: scrimsPage(),
},
],
};
};

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import ScrimPage from "#lib/features/scrims/components/ScrimPage.svelte";
import { page } from "$app/state";
const scrimPostId = $derived(Number(page.params.id));
</script>
{#key scrimPostId}
<ScrimPage {scrimPostId} />
{/key}

View File

@@ -0,0 +1,14 @@
import { navIconUrl, scrimsPage } from "#lib/utils/urls.ts";
import type { PageLoad } from "./$types";
export const load: PageLoad = () => {
return {
breadcrumbs: [
{
type: "IMAGE" as const,
imgPath: navIconUrl("scrims"),
href: scrimsPage(),
},
],
};
};

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import Main from "#lib/components/Main.svelte";
import NewScrimForm from "#lib/features/scrims/components/NewScrimForm.svelte";
</script>
<Main>
<NewScrimForm />
</Main>

View File

@@ -0,0 +1,214 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLAnchorAttributes } from "svelte/elements";
type ButtonVariant =
| "primary"
| "success"
| "destructive"
| "outlined"
| "outlined-success"
| "outlined-destructive"
| "minimal"
| "minimal-success"
| "minimal-destructive";
interface Props extends HTMLAnchorAttributes {
href: string;
/** When `true`, opens the link in a new tab with `rel="noreferrer"`. */
isExternal?: boolean;
variant?: ButtonVariant;
size?: "miniscule" | "small" | "medium" | "big";
shape?: "circle" | "square";
icon?: Snippet;
children?: Snippet;
testId?: string;
}
let {
href,
isExternal,
variant,
size,
shape,
icon,
children,
testId,
class: className,
...rest
}: Props = $props();
</script>
<a
{...rest}
{href}
target={isExternal ? "_blank" : undefined}
rel={isExternal ? "noreferrer" : undefined}
data-testid={testId}
class={[
className,
"button",
variant,
size,
shape,
]}
>
{#if icon}
<span class={["buttonIcon", size, { lonely: !children }]}>
{@render icon()}
</span>
{/if}
{#if children}{@render children()}{/if}
</a>
<style>
.button {
display: flex;
width: auto;
align-items: center;
justify-content: center;
border: var(--border-style-accent);
border-radius: var(--radius-field);
appearance: none;
background: var(--color-text-accent);
color: var(--color-text-inverse);
cursor: pointer;
font-size: var(--font-sm);
font-weight: var(--weight-bold);
padding: 0 var(--field-padding);
user-select: none;
outline-color: var(--color-text-accent);
height: var(--field-size);
white-space: nowrap;
&:focus-visible {
outline-style: solid;
outline-width: 2px;
outline-offset: 1px;
}
&:active {
transform: translateY(1px);
}
}
.outlined {
background-color: transparent;
color: var(--color-text-accent);
}
.outlined-success {
border-color: var(--color-success);
background-color: transparent;
color: var(--color-success);
outline-color: var(--color-success);
}
.outlined-destructive {
border-color: var(--color-error);
background-color: transparent;
color: var(--color-error);
outline-color: var(--color-error);
}
.small {
font-size: var(--font-xs);
height: var(--field-size-sm);
}
.miniscule {
font-size: var(--font-2xs);
height: var(--field-size-xs);
}
.big {
font-size: var(--font-md);
height: var(--field-size-lg);
}
.square {
aspect-ratio: 1 / 1;
padding: 0;
}
.circle {
border-radius: 50%;
aspect-ratio: 1 / 1;
padding: 0;
}
.minimal {
padding: 0;
border: none;
background-color: transparent;
color: var(--color-text-accent);
outline: initial;
&:focus-visible {
outline: var(--focus-ring);
}
}
.minimal-success {
padding: 0;
border: none;
background-color: transparent;
color: var(--color-success);
outline-color: var(--color-success);
}
.success {
border-color: var(--color-success);
background-color: var(--color-success);
outline-color: var(--color-success);
}
.destructive {
border-color: var(--color-error);
background-color: var(--color-error);
color: var(--color-text-inverse);
outline-color: var(--color-error);
}
.minimal-destructive {
padding: 0;
border: none;
background-color: transparent;
color: var(--color-error);
outline-color: var(--color-error);
}
span.buttonIcon {
display: inline-flex;
min-width: 20px;
max-width: 20px;
margin-inline-end: var(--s-1-5);
&.lonely {
margin-inline-end: 0 !important;
}
&.small {
min-width: 18px;
max-width: 18px;
margin-inline-end: var(--s-1);
}
&.miniscule {
min-width: 14px;
max-width: 14px;
margin-inline-end: var(--s-1);
}
&.big {
min-width: 28px;
max-width: 28px;
margin-inline-end: var(--s-2);
}
& :global(svg) {
width: 100%;
height: auto;
}
}
</style>

View File

@@ -9,6 +9,7 @@ interface Props {
isActive?: boolean;
isDestructive?: boolean;
isDisabled?: boolean;
testId?: string;
children: Snippet;
}
@@ -19,6 +20,7 @@ let {
isActive,
isDestructive,
isDisabled,
testId,
children,
}: Props = $props();
@@ -45,6 +47,7 @@ function act() {
role="menuitem"
tabindex="-1"
aria-disabled={isDisabled || undefined}
data-testid={testId}
onclick={() => menu.close()}
>
{@render content()}
@@ -56,6 +59,7 @@ function act() {
role="menuitem"
tabindex="-1"
aria-disabled={isDisabled || undefined}
data-testid={testId}
onclick={act}
>
{@render content()}

View File

@@ -2,6 +2,7 @@ export { default as Button } from "./Button.svelte";
export { default as ChipRadio } from "./ChipRadio.svelte";
export { default as ChipRadioGroup } from "./ChipRadioGroup.svelte";
export { default as Dialog } from "./Dialog.svelte";
export { default as LinkButton } from "./LinkButton.svelte";
export { default as Menu } from "./Menu.svelte";
export { default as Switch } from "./Switch.svelte";
export { default as MenuItem } from "./MenuItem.svelte";

3
pnpm-lock.yaml generated
View File

@@ -67,6 +67,9 @@ importers:
valibot:
specifier: ^1.4.2
version: 1.4.2(typescript@6.0.3)
web-haptics:
specifier: 0.0.6
version: 0.0.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.9)
devDependencies:
'@inlang/paraglide-js':
specifier: ^2.24.1