mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Scrims routes, chat rebuild UI and match-page components for apps/web
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
83
apps/web/src/lib/components/ConfirmDialog.svelte
Normal file
83
apps/web/src/lib/components/ConfirmDialog.svelte
Normal 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}
|
||||
44
apps/web/src/lib/components/Divider.svelte
Normal file
44
apps/web/src/lib/components/Divider.svelte
Normal 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>
|
||||
40
apps/web/src/lib/components/LocaleTime.svelte
Normal file
40
apps/web/src/lib/components/LocaleTime.svelte
Normal 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
|
||||
>
|
||||
148
apps/web/src/lib/components/NoteAvatar.svelte
Normal file
148
apps/web/src/lib/components/NoteAvatar.svelte
Normal 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>
|
||||
31
apps/web/src/lib/components/StageImage.svelte
Normal file
31
apps/web/src/lib/components/StageImage.svelte
Normal 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}
|
||||
/>
|
||||
58
apps/web/src/lib/components/Table.svelte
Normal file
58
apps/web/src/lib/components/Table.svelte
Normal 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>
|
||||
104
apps/web/src/lib/components/TimePopover.svelte
Normal file
104
apps/web/src/lib/components/TimePopover.svelte
Normal 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>
|
||||
297
apps/web/src/lib/components/filter-bar/FilterBar.svelte
Normal file
297
apps/web/src/lib/components/filter-bar/FilterBar.svelte
Normal 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>
|
||||
18
apps/web/src/lib/components/filter-bar/filter-bar-types.ts
Normal file
18
apps/web/src/lib/components/filter-bar/filter-bar-types.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
81
apps/web/src/lib/components/match-page/IconBanner.svelte
Normal file
81
apps/web/src/lib/components/match-page/IconBanner.svelte
Normal 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>
|
||||
545
apps/web/src/lib/components/match-page/MatchActionTab.svelte
Normal file
545
apps/web/src/lib/components/match-page/MatchActionTab.svelte
Normal 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>
|
||||
232
apps/web/src/lib/components/match-page/MatchBanner.svelte
Normal file
232
apps/web/src/lib/components/match-page/MatchBanner.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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>
|
||||
20
apps/web/src/lib/components/match-page/MatchPage.svelte
Normal file
20
apps/web/src/lib/components/match-page/MatchPage.svelte
Normal 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>
|
||||
@@ -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>
|
||||
19
apps/web/src/lib/components/match-page/MatchResultTab.svelte
Normal file
19
apps/web/src/lib/components/match-page/MatchResultTab.svelte
Normal 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>
|
||||
275
apps/web/src/lib/components/match-page/MatchRosterTab.svelte
Normal file
275
apps/web/src/lib/components/match-page/MatchRosterTab.svelte
Normal 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>
|
||||
72
apps/web/src/lib/components/match-page/MatchTabs.svelte
Normal file
72
apps/web/src/lib/components/match-page/MatchTabs.svelte
Normal 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>
|
||||
458
apps/web/src/lib/components/match-page/MatchTimeline.svelte
Normal file
458
apps/web/src/lib/components/match-page/MatchTimeline.svelte
Normal 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>
|
||||
113
apps/web/src/lib/components/match-page/SecondaryAction.svelte
Normal file
113
apps/web/src/lib/components/match-page/SecondaryAction.svelte
Normal 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>
|
||||
@@ -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];
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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)), {}),
|
||||
});
|
||||
142
apps/web/src/lib/components/match-page/match-page-utils.test.ts
Normal file
142
apps/web/src/lib/components/match-page/match-page-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
apps/web/src/lib/components/match-page/match-page-utils.ts
Normal file
83
apps/web/src/lib/components/match-page/match-page-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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?
|
||||
*
|
||||
|
||||
24
apps/web/src/lib/features/chat/chat-state.svelte.ts
Normal file
24
apps/web/src/lib/features/chat/chat-state.svelte.ts
Normal 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();
|
||||
152
apps/web/src/lib/features/chat/chat.remote.ts
Normal file
152
apps/web/src/lib/features/chat/chat.remote.ts
Normal 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;
|
||||
}
|
||||
318
apps/web/src/lib/features/chat/components/Chat.svelte
Normal file
318
apps/web/src/lib/features/chat/components/Chat.svelte
Normal 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>
|
||||
397
apps/web/src/lib/features/chat/components/ChatSidebar.svelte
Normal file
397
apps/web/src/lib/features/chat/components/ChatSidebar.svelte
Normal 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>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
48
apps/web/src/lib/features/scrims/components/ScrimPage.svelte
Normal file
48
apps/web/src/lib/features/scrims/components/ScrimPage.svelte
Normal 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>
|
||||
419
apps/web/src/lib/features/scrims/components/ScrimPostCard.svelte
Normal file
419
apps/web/src/lib/features/scrims/components/ScrimPostCard.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
112
apps/web/src/lib/features/scrims/components/ScrimsFilters.svelte
Normal file
112
apps/web/src/lib/features/scrims/components/ScrimsFilters.svelte
Normal 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}
|
||||
/>
|
||||
245
apps/web/src/lib/features/scrims/components/ScrimsPage.svelte
Normal file
245
apps/web/src/lib/features/scrims/components/ScrimsPage.svelte
Normal 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>
|
||||
@@ -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}
|
||||
@@ -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()}
|
||||
/>
|
||||
254
apps/web/src/lib/features/scrims/components/WithFormField.svelte
Normal file
254
apps/web/src/lib/features/scrims/components/WithFormField.svelte
Normal 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>
|
||||
963
apps/web/src/lib/features/scrims/scrims.remote.ts
Normal file
963
apps/web/src/lib/features/scrims/scrims.remote.ts
Normal 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>>;
|
||||
@@ -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>
|
||||
722
apps/web/src/lib/features/user-page/components/UserCard.svelte
Normal file
722
apps/web/src/lib/features/user-page/components/UserCard.svelte
Normal 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>
|
||||
24
apps/web/src/lib/features/user-page/user-card-context.ts
Normal file
24
apps/web/src/lib/features/user-page/user-card-context.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
56
apps/web/src/lib/features/user-page/user-card.remote.ts
Normal file
56
apps/web/src/lib/features/user-page/user-card.remote.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -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}`);
|
||||
|
||||
94
apps/web/src/lib/modules/intl/date-time-format.ts
Normal file
94
apps/web/src/lib/modules/intl/date-time-format.ts
Normal 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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
30
apps/web/src/lib/utils/respond.server.ts
Normal file
30
apps/web/src/lib/utils/respond.server.ts
Normal 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;
|
||||
}
|
||||
5
apps/web/src/lib/utils/sql.ts
Normal file
5
apps/web/src/lib/utils/sql.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { DBBoolean } from "#lib/server/db/tables.ts";
|
||||
|
||||
export function toDBBoolean(value: boolean): DBBoolean {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
@@ -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
8
apps/web/src/params.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
12
apps/web/src/routes/scrims/+page.svelte
Normal file
12
apps/web/src/routes/scrims/+page.svelte
Normal 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 />
|
||||
14
apps/web/src/routes/scrims/+page.ts
Normal file
14
apps/web/src/routes/scrims/+page.ts
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
10
apps/web/src/routes/scrims/[id=integer]/+page.svelte
Normal file
10
apps/web/src/routes/scrims/[id=integer]/+page.svelte
Normal 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}
|
||||
14
apps/web/src/routes/scrims/[id=integer]/+page.ts
Normal file
14
apps/web/src/routes/scrims/[id=integer]/+page.ts
Normal 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(),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
8
apps/web/src/routes/scrims/new/+page.svelte
Normal file
8
apps/web/src/routes/scrims/new/+page.svelte
Normal 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>
|
||||
214
packages/components/src/LinkButton.svelte
Normal file
214
packages/components/src/LinkButton.svelte
Normal 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>
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user