CSS modules ownership cleanup

Closes #3358
This commit is contained in:
Kalle
2026-08-21 21:51:31 +03:00
parent 5c888a5395
commit 9a237ff26a
182 changed files with 4537 additions and 4545 deletions

View File

@@ -1,5 +1,6 @@
import clsx from "clsx";
import type * as React from "react";
import { FieldError, Text } from "react-aria-components";
import styles from "./FormMessage.module.css";
export function FormMessage({
@@ -28,3 +29,31 @@ export function FormMessage({
</div>
);
}
/** Field-level error, wired to the surrounding React Aria field's validation. */
export function SendouFieldError({
children,
id,
}: {
children?: React.ReactNode;
id?: string;
}) {
return (
<FieldError className={styles.error} id={id}>
{children}
</FieldError>
);
}
/** Field-level hint below an input. */
export function SendouFieldMessage({
children,
}: {
children: React.ReactNode;
}) {
return (
<Text slot="description" className={styles.info}>
{children}
</Text>
);
}

View File

@@ -0,0 +1,16 @@
.item {
display: flex;
gap: var(--s-2);
align-items: center;
}
.weaponImg {
min-width: 24px;
}
.weaponLabel {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}

View File

@@ -14,7 +14,7 @@ import {
import type { GearType } from "~/modules/in-game-lists/types";
import { brandImageUrl, gearImageUrl } from "~/utils/urls";
import styles from "./WeaponSelect.module.css";
import styles from "./GearSelect.module.css";
interface GearSelectProps<Clearable extends boolean | undefined = undefined> {
label?: string;

View File

@@ -33,7 +33,14 @@ export function Label({
{children} {required && <span className="text-error">*</span>}
</label>
{valueLimits ? (
<div className={clsx(styles.value, lengthWarning(valueLimits))}>
<div
className={clsx(styles.value, {
[styles.valueError]: lengthState(valueLimits) === "error",
[styles.valueWarning]: lengthState(valueLimits) === "warning",
})}
data-testid="label-value-counter"
data-length-state={lengthState(valueLimits)}
>
{valueLimits.current}/{valueLimits.max}
</div>
) : null}
@@ -41,9 +48,9 @@ export function Label({
);
}
function lengthWarning(valueLimits: NonNullable<LabelProps["valueLimits"]>) {
if (valueLimits.current > valueLimits.max) return styles.valueError;
if (valueLimits.current / valueLimits.max >= 0.9) return styles.valueWarning;
function lengthState(valueLimits: NonNullable<LabelProps["valueLimits"]>) {
if (valueLimits.current > valueLimits.max) return "error";
if (valueLimits.current / valueLimits.max >= 0.9) return "warning";
return;
return undefined;
}

View File

@@ -6,27 +6,12 @@
font-weight: var(--weight-semi);
}
.tableRank {
.rank {
min-width: 28px;
text-align: right;
}
.tableName {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 117px;
}
.tierHeader {
display: flex;
align-items: center;
gap: var(--s-2);
margin-block: var(--s-2);
color: var(--color-text-high);
}
.tableRow {
.row {
background-color: var(--color-bg-high);
display: flex;
padding: var(--s-2) var(--s-3);
@@ -49,48 +34,18 @@
}
}
a.tableRow:hover {
a.row:hover {
background-color: var(--color-bg-higher);
}
.tableRowQualification {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
justify-content: center;
background-color: var(--color-bg-higher);
display: flex;
gap: var(--s-2);
}
.tableWeapon {
.weapon {
background-color: var(--color-bg);
border-radius: 100%;
}
.tableMode {
background-color: var(--color-bg);
border-radius: 100%;
padding: var(--s-1);
}
.time {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.tablePower {
margin-inline-start: auto;
}
.tableInnerRow {
.innerRow {
display: flex;
align-items: center;
gap: var(--s-3);
width: 100%;
}
.avatar {
min-width: 24px;
min-height: 24px;
}

View File

@@ -0,0 +1,62 @@
/**
* Rows of ranked entries (rank, avatar, weapon, name, power) as rendered by the
* leaderboards and the X Rank top search pages.
*/
import clsx from "clsx";
import type * as React from "react";
import { Link } from "react-router";
import { WeaponImage } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import styles from "./RankTable.module.css";
export function RankTable({ children }: { children: React.ReactNode }) {
return <div className={styles.table}>{children}</div>;
}
export function RankTableRow({
to,
className,
testId,
children,
}: {
/** when given the row is a link to the entry's own page */
to?: string;
className?: string;
testId?: string;
children: React.ReactNode;
}) {
if (!to) {
return <div className={clsx(styles.row, className)}>{children}</div>;
}
return (
<Link to={to} className={clsx(styles.row, className)} data-testid={testId}>
{children}
</Link>
);
}
export function RankTableInnerRow({ children }: { children: React.ReactNode }) {
return <div className={styles.innerRow}>{children}</div>;
}
export function RankTableRank({ children }: { children: React.ReactNode }) {
return <div className={styles.rank}>{children}</div>;
}
export function RankTableWeaponImage({
weaponSplId,
}: {
weaponSplId: MainWeaponId;
}) {
return (
<WeaponImage
className={styles.weapon}
variant="build"
weaponSplId={weaponSplId}
width={32}
height={32}
/>
);
}

View File

@@ -57,126 +57,6 @@
flex-shrink: 0;
}
.sideNavFooterUser {
display: flex;
align-items: center;
gap: var(--s-2);
color: var(--color-text);
text-decoration: none;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
border-radius: var(--radius-field);
padding: var(--s-1);
margin-left: calc(-1 * var(--s-1));
flex: 1;
min-width: 0;
&:hover {
background-color: var(--color-bg-high);
}
}
.sideNavFooterUsername {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sideNavFooterActions {
display: flex;
align-items: center;
gap: var(--s-0-5);
margin-left: auto;
}
.sideNavFooterButton {
display: grid;
place-items: center;
background-color: transparent;
width: var(--field-size-sm);
height: var(--field-size-sm);
padding: 0;
border: none;
border-radius: var(--radius-field);
color: var(--color-text-high);
cursor: pointer;
&:hover {
background-color: var(--color-bg-high);
color: var(--color-text);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: -2px;
}
& svg {
width: 18px;
height: 18px;
}
}
.sideNavFooterNotification {
position: relative;
}
.sideNavFooterUnseenDot {
--dot-top: 2px;
--dot-right: 2px;
}
.sideNavHeader {
color: var(--color-text-high);
padding: var(--s-1-5) var(--s-2);
margin-inline: calc(-1 * var(--s-1-5));
background-color: var(--color-bg-high);
display: flex;
align-items: center;
gap: var(--s-2);
border-color: var(--color-border);
border-top: 1.5px solid var(--color-border);
border-bottom: 1.5px solid var(--color-border);
height: var(--layout-nav-height);
&:first-child {
margin-block-start: calc(-1 * var(--s-1-5));
border-top: none;
}
& h2 {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
& svg {
width: 18px;
}
}
.sideNavHeaderAction {
display: flex;
align-items: center;
margin-inline-start: auto;
font-size: var(--font-2xs);
font-weight: var(--weight-normal);
& a {
color: var(--color-text-high);
text-decoration: none;
&:hover {
color: var(--color-text);
text-decoration: underline;
}
}
}
.sideNavHeaderClose {
margin-inline-start: auto;
margin-inline-end: var(--s-1);
}
.iconContainer {
background-color: var(--color-bg-high);
border-radius: var(--radius-field);
@@ -301,6 +181,57 @@
background-color: var(--color-text-second);
}
.sideNavHeader {
color: var(--color-text-high);
padding: var(--s-1-5) var(--s-2);
margin-inline: calc(-1 * var(--s-1-5));
background-color: var(--color-bg-high);
display: flex;
align-items: center;
gap: var(--s-2);
border-color: var(--color-border);
border-top: 1.5px solid var(--color-border);
border-bottom: 1.5px solid var(--color-border);
height: var(--layout-nav-height);
&:first-child {
margin-block-start: calc(-1 * var(--s-1-5));
border-top: none;
}
& h2 {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
& svg {
width: 18px;
}
}
.sideNavHeaderAction {
display: flex;
align-items: center;
margin-inline-start: auto;
font-size: var(--font-2xs);
font-weight: var(--weight-normal);
& a {
color: var(--color-text-high);
text-decoration: none;
&:hover {
color: var(--color-text);
text-decoration: underline;
}
}
}
.sideNavHeaderClose {
margin-inline-start: auto;
margin-inline-end: var(--s-1);
}
@media screen and (min-width: 1475px) {
:global(html[data-fuse="true"]) .sideNav {
z-index: 20;

View File

@@ -218,3 +218,47 @@ export function ListButton({
export function SideNavFooter({ children }: { children: React.ReactNode }) {
return <div className={styles.sideNavFooter}>{children}</div>;
}
export function NavIconContainer({ children }: { children: React.ReactNode }) {
return <div className={styles.iconContainer}>{children}</div>;
}
export function NavListButton({
children,
className,
onPress,
}: {
children: React.ReactNode;
className?: string;
onPress: () => void;
}) {
return (
<Button className={clsx(styles.listButton, className)} onPress={onPress}>
{children}
</Button>
);
}
export function NavListImage({ src }: { src: string }) {
return <img src={src} alt="" className={styles.listLinkImage} />;
}
export function NavListTexts({ children }: { children: React.ReactNode }) {
return <div className={styles.listLinkContent}>{children}</div>;
}
export function NavListTitle({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<span className={clsx(styles.listLinkTitle, className)}>{children}</span>
);
}
export function NavListSubtitle({ children }: { children: React.ReactNode }) {
return <span className={styles.listLinkSubtitle}>{children}</span>;
}

View File

@@ -1,11 +1,10 @@
import clsx from "clsx";
import { Check, Clipboard } from "lucide-react";
import { useRef, useState } from "react";
import { Dialog, Popover } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { SendouButton } from "./elements/Button";
import popoverStyles from "./elements/Popover.module.css";
import { SendouAnchoredPopover } from "./elements/Popover";
import { LocaleTime } from "./LocaleTime";
import styles from "./TimePopover.module.css";
@@ -52,40 +51,37 @@ export default function TimePopover({
>
<LocaleTime date={date} options={options} inline />
</button>
<Popover
<SendouAnchoredPopover
isOpen={open}
className={popoverStyles.content}
onOpenChange={setOpen}
triggerRef={triggerRef}
>
<Dialog className={popoverStyles.dialog}>
<div className="stack sm">
<div className="text-center">
<LocaleTime
date={date}
options={{
timeZoneName: "long",
hour: "numeric",
minute: "numeric",
}}
/>
</div>
<SendouButton
size="miniscule"
variant="minimal"
onPress={() => copyToClipboard(`<t:${date.valueOf() / 1000}:F>`)}
icon={copySuccess ? <Check /> : <Clipboard />}
>
{t("common:actions.copyTimestampForDiscord")}
</SendouButton>
{footerText ? (
<div className="text-lighter text-center mt-2 text-xs">
{footerText}
</div>
) : null}
<div className="stack sm">
<div className="text-center">
<LocaleTime
date={date}
options={{
timeZoneName: "long",
hour: "numeric",
minute: "numeric",
}}
/>
</div>
</Dialog>
</Popover>
<SendouButton
size="miniscule"
variant="minimal"
onPress={() => copyToClipboard(`<t:${date.valueOf() / 1000}:F>`)}
icon={copySuccess ? <Check /> : <Clipboard />}
>
{t("common:actions.copyTimestampForDiscord")}
</SendouButton>
{footerText ? (
<div className="text-lighter text-center mt-2 text-xs">
{footerText}
</div>
) : null}
</div>
</SendouAnchoredPopover>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { SendouFieldError } from "~/components/elements/FieldError";
import { SendouFieldMessage } from "~/components/elements/FieldMessage";
import { SendouFieldError, SendouFieldMessage } from "~/components/FormMessage";
// TODO: deprecate in favor of FormMessage
export function SendouBottomTexts({

View File

@@ -138,7 +138,8 @@ function buttonClassName({
const variantToClassname = (variant: ButtonVariant) => {
switch (variant) {
case "primary":
return styles.primary;
// the base look, no extra class needed
return null;
case "success":
return styles.success;
case "destructive":
@@ -188,3 +189,17 @@ function iconClassName(
[styles.buttonIconBig]: size === "big",
});
}
/**
* Renders the button look on a plain element, for when the interactive element
* is elsewhere, e.g. the box inside a tab.
*/
export function ButtonLook({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return <div className={clsx(styles.button, className)}>{children}</div>;
}

View File

@@ -1,16 +0,0 @@
import { FieldError as ReactAriaFieldError } from "react-aria-components";
import styles from "../FormMessage.module.css";
export function SendouFieldError({
children,
id,
}: {
children?: React.ReactNode;
id?: string;
}) {
return (
<ReactAriaFieldError className={styles.error} id={id}>
{children}
</ReactAriaFieldError>
);
}

View File

@@ -1,14 +0,0 @@
import { Text } from "react-aria-components";
import styles from "../FormMessage.module.css";
export function SendouFieldMessage({
children,
}: {
children: React.ReactNode;
}) {
return (
<Text slot="description" className={styles.info}>
{children}
</Text>
);
}

View File

@@ -1,11 +1,12 @@
import clsx from "clsx";
import * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import type { SelectProps } from "react-aria-components";
import { useFetcher } from "react-router";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import {
SearchSelect,
SearchSelectItem,
SearchSelectItemAdditionalText,
} from "./SearchSelect";
import { useEntitySearch } from "./useEntitySearch";
export type OrganizationSearchResult = Extract<
@@ -100,23 +101,15 @@ function useInitialOrganization(initialOrganizationId?: number) {
function OrganizationItem({ item }: { item: OrganizationSearchResult }) {
return (
<ListBoxItem
<SearchSelectItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="organization-search-item"
testId="organization-search-item"
>
<div className={searchSelectStyles.itemTextsContainer}>
{item.name}
<div className={searchSelectStyles.itemAdditionalText}>
/{item.slug}
</div>
</div>
</ListBoxItem>
{item.name}
<SearchSelectItemAdditionalText>
/{item.slug}
</SearchSelectItemAdditionalText>
</SearchSelectItem>
);
}

View File

@@ -47,3 +47,31 @@ export function SendouPopover({
</DialogTrigger>
);
}
/**
* Popover anchored to a trigger rendered outside of it, with its open state
* controlled by the caller. Prefer `SendouPopover` when the trigger can be
* passed in.
*/
export function SendouAnchoredPopover({
children,
isOpen,
onOpenChange,
triggerRef,
}: {
children: React.ReactNode;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
triggerRef: React.RefObject<HTMLElement | null>;
}) {
return (
<Popover
isOpen={isOpen}
className={styles.content}
onOpenChange={onOpenChange}
triggerRef={triggerRef}
>
<Dialog className={styles.dialog}>{children}</Dialog>
</Popover>
);
}

View File

@@ -1,24 +1,23 @@
import clsx from "clsx";
import { ChevronsUpDown, Search, X } from "lucide-react";
import type * as React from "react";
import {
Autocomplete,
Button,
Input,
type Key,
ListBox,
ListBoxItem,
Popover,
SearchField,
Select,
type SelectProps,
SelectValue,
} from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouLabel } from "~/components/elements/Label";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import styles from "./SearchSelect.module.css";
import {
SelectShell,
SelectShellItem,
SelectShellListBox,
SelectShellPopover,
SelectShellSearchField,
SelectShellTrigger,
} from "./SelectShell";
import type { EntitySearch } from "./useEntitySearch";
const PLACEHOLDER_TEXTS = {
@@ -80,7 +79,7 @@ export function SearchSelect<
...rest
}: SearchSelectProps<TItem, T>) {
return (
<Select
<SelectShell
name={name}
placeholder=""
selectedKey={search.selectedKey}
@@ -89,42 +88,26 @@ export function SearchSelect<
search.onSelectionChange(Number(key));
}
}}
className={selectStyles.select}
aria-label={ariaLabel}
{...rest}
>
{label ? (
<SendouLabel required={rest.isRequired}>{label}</SendouLabel>
) : null}
<Button className={selectStyles.button} ref={buttonRef}>
<SelectValue className={searchSelectStyles.selectValue} />
<span aria-hidden="true">
<ChevronsUpDown className={selectStyles.icon} />
</span>
</Button>
<SelectShellTrigger ref={buttonRef}>
<SelectValue className={styles.selectValue} />
</SelectShellTrigger>
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<Popover
className={clsx(selectStyles.popover, searchSelectStyles.popover)}
>
<SelectShellPopover className={styles.popover}>
<Autocomplete
inputValue={search.filterText}
onInputChange={search.setFilterText}
>
<SearchField
aria-label="Search"
autoFocus
className={selectStyles.searchField}
>
<Search aria-hidden className={selectStyles.icon} />
<Input
className={clsx(inputClassName, selectStyles.searchInput)}
data-testid={inputTestId}
/>
<Button className={selectStyles.searchClearButton}>
<X className={selectStyles.icon} />
</Button>
</SearchField>
<ListBox items={search.items} className={selectStyles.listBox}>
<SelectShellSearchField
inputClassName={inputClassName}
inputTestId={inputTestId}
/>
<SelectShellListBox items={search.items}>
{(item) =>
typeof item.id === "string" ? (
<PlaceholderItem id={item.id} i18nKey={i18nKey} />
@@ -132,10 +115,10 @@ export function SearchSelect<
renderItem(item as TItem)
)
}
</ListBox>
</SelectShellListBox>
</Autocomplete>
</Popover>
</Select>
</SelectShellPopover>
</SelectShell>
);
}
@@ -154,7 +137,7 @@ function PlaceholderItem({
<ListBoxItem
textValue="PLACEHOLDER"
isDisabled
className={searchSelectStyles.placeholder}
className={styles.placeholder}
>
{id === "PLACEHOLDER"
? t(PLACEHOLDER_TEXTS[i18nKey].placeholder)
@@ -162,3 +145,46 @@ function PlaceholderItem({
</ListBoxItem>
);
}
/**
* One result inside a `SearchSelect`'s list: an optional leading avatar or
* logo, then the texts. `SearchSelectItemAdditionalText` renders the muted
* second line, which is hidden while the item is shown in the trigger.
*/
export function SearchSelectItem({
id,
textValue,
testId,
leading,
children,
}: {
id: number;
textValue: string;
testId: string;
leading?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<SelectShellItem
id={id}
textValue={textValue}
className={styles.item}
data-testid={testId}
>
{leading}
<div className={styles.itemTextsContainer}>{children}</div>
</SelectShellItem>
);
}
export function SearchSelectItemLogo({ src }: { src: string }) {
return <img src={src} alt="" className={styles.logo} />;
}
export function SearchSelectItemAdditionalText({
children,
}: {
children: React.ReactNode;
}) {
return <div className={styles.itemAdditionalText}>{children}</div>;
}

View File

@@ -1,32 +1,3 @@
.button {
height: var(--field-size);
padding: 0 var(--field-padding);
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
outline: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-1-5);
width: 100%;
cursor: pointer;
&[data-focus-visible],
&[aria-expanded="true"] {
outline: var(--focus-ring);
outline-offset: 1px;
}
&[data-disabled] {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
outline: none;
}
}
.selectValue {
font-size: var(--font-sm);
overflow: hidden;
@@ -40,29 +11,6 @@
}
}
.icon {
min-width: 18px;
max-width: 18px;
color: var(--color-text-high);
}
.popover {
padding: var(--s-1);
width: var(--trigger-width);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg);
outline: none;
display: flex;
flex-direction: column;
}
.listBox {
overflow: auto;
flex: 1;
}
.item {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
@@ -77,64 +25,6 @@
}
}
.itemFocused {
background-color: var(--color-bg-high);
color: var(--color-text);
}
.itemSelected {
color: var(--color-text-accent);
font-weight: var(--weight-bold);
}
.searchField {
position: relative;
display: flex;
gap: var(--s-2);
border-bottom: 1px solid var(--color-border);
border-radius: 0;
accent-color: var(--color-accent);
color: var(--color-text);
outline: none;
padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2))
var(--s-1-5);
margin-block-end: var(--s-1-5);
}
.searchInput {
all: unset;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
letter-spacing: 0.5px;
flex: 1;
min-width: 0;
padding-inline-end: calc(18px + var(--s-2));
&::-webkit-search-cancel-button {
display: none;
}
&::placeholder {
color: var(--color-text-high);
}
}
.searchClearButton {
position: absolute;
right: var(--s-2);
top: 50%;
transform: translateY(-50%);
background-color: transparent;
border: none;
padding: 0;
cursor: pointer;
}
[data-empty] .searchClearButton {
visibility: hidden;
}
.noResults {
font-size: var(--font-md);
font-weight: var(--weight-bold);
@@ -143,14 +33,6 @@
color: var(--color-text-high);
}
.select {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
}
.label {
font-size: var(--font-xs);
font-weight: var(--weight-bold);

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import { ChevronsUpDown, Search, X } from "lucide-react";
import { X } from "lucide-react";
import * as React from "react";
import type {
AutocompleteProps,
@@ -8,17 +8,10 @@ import type {
} from "react-aria-components";
import {
Autocomplete,
Button,
Header,
Input,
Label,
ListBox,
ListBoxItem,
ListBoxSection,
ListLayout,
Popover,
SearchField,
Select,
SelectStateContext,
SelectValue,
useFilter,
@@ -29,6 +22,14 @@ import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouButton } from "~/components/elements/Button";
import { Image } from "../Image";
import styles from "./Select.module.css";
import {
SelectShell,
SelectShellItem,
SelectShellListBox,
SelectShellPopover,
SelectShellSearchField,
SelectShellTrigger,
} from "./SelectShell";
const ROW_HEIGHT = 33;
@@ -108,15 +109,15 @@ export function SendouSelect<T extends object>({
estimatedRowHeight ? { estimatedRowHeight } : { rowHeight: ROW_HEIGHT }
}
>
<ListBox
<SelectShellListBox
items={items}
className={clsx(styles.listBox, "scrollbar")}
className="scrollbar"
renderEmptyState={() => (
<div className={styles.noResults}>{t("common:noResults")}</div>
)}
>
{children}
</ListBox>
</SelectShellListBox>
</Virtualizer>
);
@@ -126,21 +127,18 @@ export function SendouSelect<T extends object>({
const filterable = !!search || isControlled || !!filter;
return (
<Select
<SelectShell
{...props}
className={clsx(className, styles.select)}
className={className}
onOpenChange={handleOpenChange}
>
{label ? <Label className={styles.label}>{label}</Label> : null}
<Button className={styles.button}>
<SelectShellTrigger>
<SelectValue className={styles.selectValue} />
<span aria-hidden="true">
<ChevronsUpDown className={styles.icon} />
</span>
</Button>
</SelectShellTrigger>
{clearable ? <SelectClearButton /> : null}
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<Popover className={clsx(popoverClassName, styles.popover)}>
<SelectShellPopover className={popoverClassName}>
{filterable ? (
<Autocomplete
filter={filter ? filter : isControlled ? undefined : contains}
@@ -148,45 +146,25 @@ export function SendouSelect<T extends object>({
onInputChange={onSearchInputChange}
>
{search ? (
<SearchField
aria-label="Search"
autoFocus
className={styles.searchField}
>
<Search aria-hidden className={styles.icon} />
<Input
placeholder={search.placeholder}
className={clsx(styles.searchInput, "in-container")}
/>
<Button className={styles.searchClearButton}>
<X className={styles.icon} />
</Button>
</SearchField>
<SelectShellSearchField
placeholder={search.placeholder}
inputClassName="in-container"
/>
) : null}
{listBox}
</Autocomplete>
) : (
listBox
)}
</Popover>
</Select>
</SelectShellPopover>
</SelectShell>
);
}
interface SendouSelectItemProps extends ListBoxItemProps {}
export function SendouSelectItem(props: SendouSelectItemProps) {
return (
<ListBoxItem
{...props}
className={({ isFocused, isSelected }) =>
clsx(styles.item, {
[styles.itemFocused]: isFocused,
[styles.itemSelected]: isSelected,
})
}
/>
);
return <SelectShellItem {...props} className={styles.item} />;
}
interface SendouSelectItemSectionProps {

View File

@@ -0,0 +1,117 @@
.button {
height: var(--field-size);
padding: 0 var(--field-padding);
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
outline: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-1-5);
width: 100%;
cursor: pointer;
&[data-focus-visible],
&[aria-expanded="true"] {
outline: var(--focus-ring);
outline-offset: 1px;
}
&[data-disabled] {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
outline: none;
}
}
.icon {
min-width: 18px;
max-width: 18px;
color: var(--color-text-high);
}
.popover {
padding: var(--s-1);
width: var(--trigger-width);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg);
outline: none;
display: flex;
flex-direction: column;
}
.listBox {
overflow: auto;
flex: 1;
}
.itemFocused {
background-color: var(--color-bg-high);
color: var(--color-text);
}
.itemSelected {
color: var(--color-text-accent);
font-weight: var(--weight-bold);
}
.searchField {
position: relative;
display: flex;
gap: var(--s-2);
border-bottom: 1px solid var(--color-border);
border-radius: 0;
accent-color: var(--color-accent);
color: var(--color-text);
outline: none;
padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2))
var(--s-1-5);
margin-block-end: var(--s-1-5);
}
.searchInput {
all: unset;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
letter-spacing: 0.5px;
flex: 1;
min-width: 0;
padding-inline-end: calc(18px + var(--s-2));
&::-webkit-search-cancel-button {
display: none;
}
&::placeholder {
color: var(--color-text-high);
}
}
.searchClearButton {
position: absolute;
right: var(--s-2);
top: 50%;
transform: translateY(-50%);
background-color: transparent;
border: none;
padding: 0;
cursor: pointer;
}
[data-empty] .searchClearButton {
visibility: hidden;
}
.select {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
}

View File

@@ -0,0 +1,126 @@
/**
* The select look shared by `SendouSelect` and `SearchSelect`: the trigger
* button, the popover with its search field, and the list box items' focus and
* selection states. Both selects render their own contents inside these.
*/
import clsx from "clsx";
import { ChevronsUpDown, Search, X } from "lucide-react";
import type * as React from "react";
import {
Button,
Input,
ListBox,
ListBoxItem,
type ListBoxItemProps,
Popover,
SearchField,
Select,
type SelectProps,
} from "react-aria-components";
import styles from "./SelectShell.module.css";
export function SelectShell<T extends object>({
className,
children,
...rest
}: SelectProps<T> & { children: React.ReactNode }) {
return (
<Select {...rest} className={clsx(className, styles.select)}>
{children}
</Select>
);
}
export function SelectShellTrigger({
ref,
children,
}: {
ref?: React.Ref<HTMLButtonElement>;
children: React.ReactNode;
}) {
return (
<Button className={styles.button} ref={ref}>
{children}
<span aria-hidden="true">
<ChevronsUpDown className={styles.icon} />
</span>
</Button>
);
}
export function SelectShellPopover({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<Popover className={clsx(className, styles.popover)}>{children}</Popover>
);
}
export function SelectShellSearchField({
placeholder,
inputClassName,
inputTestId,
}: {
placeholder?: string;
inputClassName?: string;
inputTestId?: string;
}) {
return (
<SearchField aria-label="Search" autoFocus className={styles.searchField}>
<Search aria-hidden className={styles.icon} />
<Input
placeholder={placeholder}
className={clsx(inputClassName, styles.searchInput)}
data-testid={inputTestId}
/>
<Button className={styles.searchClearButton}>
<X className={styles.icon} />
</Button>
</SearchField>
);
}
export function SelectShellListBox<T extends object>({
items,
className,
renderEmptyState,
children,
}: {
items?: Iterable<T>;
className?: string;
renderEmptyState?: () => React.ReactNode;
children: React.ReactNode | ((item: T) => React.ReactNode);
}) {
return (
<ListBox
items={items}
className={clsx(className, styles.listBox)}
renderEmptyState={renderEmptyState}
>
{children}
</ListBox>
);
}
/** list box item carrying the shared focus/selection styling */
export function SelectShellItem({
className,
...rest
}: Omit<ListBoxItemProps, "className"> & { className?: string }) {
return (
<ListBoxItem
{...rest}
className={({ isFocused, isSelected }) =>
clsx(className, {
[styles.itemFocused]: isFocused,
[styles.itemSelected]: isSelected,
})
}
/>
);
}

View File

@@ -12,7 +12,7 @@ import {
} from "react-aria-components";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import buttonStyles from "./Button.module.css";
import { ButtonLook } from "./Button";
import styles from "./Tabs.module.css";
interface SendouTabsProps extends TabsProps {
@@ -103,14 +103,14 @@ export function SendouTab({
}: SendouTabProps) {
return (
<Tab className={styles.tabContainer} {...rest}>
<div className={clsx(buttonStyles.button, styles.tabButton)}>
<ButtonLook className={styles.tabButton}>
{icon}
{children}
{typeof number === "number" && number !== 0 && (
<span className={styles.tabNumber}>{number}</span>
)}
{alert ? <TriangleAlert className={styles.tabAlert} /> : null}
</div>
</ButtonLook>
</Tab>
);
}

View File

@@ -1,10 +1,11 @@
import clsx from "clsx";
import type * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import type { SelectProps } from "react-aria-components";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import {
SearchSelect,
SearchSelectItem,
SearchSelectItemLogo,
} from "./SearchSelect";
import teamSearchStyles from "./TeamSearch.module.css";
import { useEntitySearch } from "./useEntitySearch";
@@ -73,25 +74,19 @@ function parseTeamResults(
function TeamItem({ item }: { item: TeamSearchResult }) {
return (
<ListBoxItem
<SearchSelectItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
testId="team-search-item"
leading={
item.avatarUrl ? (
<SearchSelectItemLogo src={item.avatarUrl} />
) : (
<div className={teamSearchStyles.logoPlaceholder} />
)
}
data-testid="team-search-item"
>
{item.avatarUrl ? (
<img src={item.avatarUrl} alt="" className={searchSelectStyles.logo} />
) : (
<div className={teamSearchStyles.logoPlaceholder} />
)}
<div className={searchSelectStyles.itemTextsContainer}>
<span>{item.name}</span>
</div>
</ListBoxItem>
<span>{item.name}</span>
</SearchSelectItem>
);
}

View File

@@ -1,13 +1,15 @@
import clsx from "clsx";
import { sub } from "date-fns";
import type * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import type { SelectProps } from "react-aria-components";
import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search";
import { tournamentSearchSearchParams } from "~/features/tournament/tournament-search-params";
import { LocaleTime } from "../LocaleTime";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import {
SearchSelect,
SearchSelectItem,
SearchSelectItemAdditionalText,
SearchSelectItemLogo,
} from "./SearchSelect";
import { useEntitySearch } from "./useEntitySearch";
export type TournamentSearchItem = NonNullable<
@@ -90,20 +92,14 @@ function parseTournamentResults(
function TournamentItem({ item }: { item: TournamentSearchItem }) {
return (
<ListBoxItem
<SearchSelectItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="tournament-search-item"
testId="tournament-search-item"
leading={<SearchSelectItemLogo src={item.logoUrl} />}
>
<img src={item.logoUrl} alt="" className={searchSelectStyles.logo} />
<div className={searchSelectStyles.itemTextsContainer}>
<span>{item.name}</span>
<span>{item.name}</span>
<SearchSelectItemAdditionalText>
<LocaleTime
date={item.startsAt}
options={{
@@ -112,9 +108,8 @@ function TournamentItem({ item }: { item: TournamentSearchItem }) {
year: "numeric",
}}
inline
className={searchSelectStyles.itemAdditionalText}
/>
</div>
</ListBoxItem>
</SearchSelectItemAdditionalText>
</SearchSelectItem>
);
}

View File

@@ -1,12 +1,13 @@
import clsx from "clsx";
import * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import type { SelectProps } from "react-aria-components";
import { useFetcher } from "react-router";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { Avatar } from "../Avatar";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import {
SearchSelect,
SearchSelectItem,
SearchSelectItemAdditionalText,
} from "./SearchSelect";
import { useEntitySearch } from "./useEntitySearch";
export type UserSearchResult = Extract<
@@ -119,26 +120,18 @@ function UserItem({ item }: { item: UserSearchResult }) {
};
return (
<ListBoxItem
<SearchSelectItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="user-search-item"
testId="user-search-item"
leading={<Avatar user={item} size="xxs" />}
>
<Avatar user={item} size="xxs" />
<div className={searchSelectStyles.itemTextsContainer}>
{item.name}
{additionalText() ? (
<div className={searchSelectStyles.itemAdditionalText}>
{additionalText()}
</div>
) : null}
</div>
</ListBoxItem>
{item.name}
{additionalText() ? (
<SearchSelectItemAdditionalText>
{additionalText()}
</SearchSelectItemAdditionalText>
) : null}
</SearchSelectItem>
);
}

View File

@@ -14,7 +14,14 @@ import { Chat } from "~/features/chat/components/Chat";
import { useChatContext } from "~/features/chat/useChatContext";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import sideNavStyles from "../SideNav.module.css";
import {
NavIconContainer,
NavListButton,
NavListImage,
NavListSubtitle,
NavListTexts,
NavListTitle,
} from "../SideNav";
import styles from "./ChatSidebar.module.css";
export function ChatSidebar({ onClose }: { onClose?: () => void }) {
@@ -38,9 +45,9 @@ function SidebarHeader({ onClose }: { onClose?: () => void }) {
return (
<div className={styles.sidebarHeader}>
<div className={sideNavStyles.iconContainer}>
<NavIconContainer>
<MessageSquare size={18} />
</div>
</NavIconContainer>
<h2>{t("common:chat.sidebar.title")}</h2>
{onClose ? (
<Button className={styles.closeButton} onPress={onClose}>
@@ -136,26 +143,18 @@ function RoomList({ onClose }: { onClose?: () => void }) {
const unread = chatContext.unreadCounts[room.chatCode] ?? 0;
return (
<Button
<NavListButton
key={room.chatCode}
className={clsx(
sideNavStyles.listButton,
styles.roomItem,
room.isObsolete ? "opaque" : null,
)}
onPress={() => openRooms([room.chatCode])}
>
{room.imageUrl ? (
<img
src={room.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
<div className={sideNavStyles.listLinkContent}>
<span
{room.imageUrl ? <NavListImage src={room.imageUrl} /> : null}
<NavListTexts>
<NavListTitle
className={clsx(
sideNavStyles.listLinkTitle,
styles.roomName,
room.isObsolete ? "line-through" : null,
)}
@@ -164,11 +163,9 @@ function RoomList({ onClose }: { onClose?: () => void }) {
room.header,
(d) => headerFormatter.format(d) ?? "",
)}
</span>
<span className={sideNavStyles.listLinkSubtitle}>
{room.subtitle}
</span>
</div>
</NavListTitle>
<NavListSubtitle>{room.subtitle}</NavListSubtitle>
</NavListTexts>
{unread > 0 && !room.isObsolete ? (
<span className={styles.unreadBadge}>{unread}</span>
) : room.lastMessageTimestamp > 0 ? (
@@ -178,7 +175,7 @@ function RoomList({ onClose }: { onClose?: () => void }) {
)}
</span>
) : null}
</Button>
</NavListButton>
);
})}
</>
@@ -210,30 +207,21 @@ function CombinedRoomListItem({
);
return (
<Button
className={clsx(sideNavStyles.listButton, styles.roomItem)}
onPress={onPress}
>
{primary.imageUrl ? (
<img
src={primary.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
<div className={sideNavStyles.listLinkContent}>
<span className={clsx(sideNavStyles.listLinkTitle, styles.roomName)}>
<NavListButton className={styles.roomItem} onPress={onPress}>
{primary.imageUrl ? <NavListImage src={primary.imageUrl} /> : null}
<NavListTexts>
<NavListTitle className={styles.roomName}>
{resolveDatePlaceholders(
primary.header,
(d) => headerFormatter.format(d) ?? "",
)}
</span>
<span className={sideNavStyles.listLinkSubtitle}>
</NavListTitle>
<NavListSubtitle>
{rooms.map((room) => roomShortLabel(room.header)).join(" · ")}
</span>
</div>
</NavListSubtitle>
</NavListTexts>
{unread > 0 ? <span className={styles.unreadBadge}>{unread}</span> : null}
</Button>
</NavListButton>
);
}
@@ -302,13 +290,7 @@ function SingleChatView({ onClose }: { onClose?: () => void }) {
const headerContent = (
<>
{room?.imageUrl ? (
<img
src={room.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
{room?.imageUrl ? <NavListImage src={room.imageUrl} /> : null}
<div className={styles.chatHeaderInfo}>
<span
className={clsx(
@@ -391,13 +373,7 @@ function CombinedChatView({
const primary = rooms[0];
const headerContent = (
<>
{primary.imageUrl ? (
<img
src={primary.imageUrl}
alt=""
className={sideNavStyles.listLinkImage}
/>
) : null}
{primary.imageUrl ? <NavListImage src={primary.imageUrl} /> : null}
<div className={styles.chatHeaderInfo}>
<span className={styles.chatHeaderTitle}>
{resolveDatePlaceholders(

View File

@@ -58,15 +58,6 @@
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
width: 100%;
max-width: 36rem;
@@ -83,17 +74,6 @@
}
}
@keyframes zoom-in-95 {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.dialog {
outline: none;
}
@@ -145,36 +125,6 @@
flex-shrink: 0;
}
.listBox {
max-height: 325px;
overflow-y: auto;
padding: var(--s-2);
outline: none;
}
.listBoxItem {
display: block;
padding: var(--s-2) var(--s-3);
border-radius: var(--radius-field);
cursor: pointer;
font-size: var(--font-sm);
outline: none;
color: inherit;
text-decoration: none;
&[data-hovered] {
background-color: var(--color-bg-high);
}
&[data-focused] {
background-color: var(--color-bg-high);
}
&[data-selected] {
background-color: var(--color-accent-low);
}
}
.searchTypeContainer {
padding: var(--s-4);
border-bottom: 1px solid var(--color-border);
@@ -226,24 +176,12 @@
outline-offset: 1px;
}
.resultItem {
display: flex;
align-items: center;
gap: var(--s-2);
}
.resultTexts {
display: flex;
flex-direction: column;
min-width: 0;
}
.resultName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resultSecondary {
font-size: var(--font-xs);
color: var(--color-text-high);
@@ -257,46 +195,29 @@
object-fit: cover;
}
.emptyState {
padding: var(--s-8);
text-align: center;
color: var(--color-text-high);
font-size: var(--font-sm);
}
.weaponDestinationHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--color-border);
}
.backButton {
display: flex;
align-items: center;
justify-content: center;
height: var(--field-size-sm);
aspect-ratio: 1 / 1;
border: 1px solid var(--color-border);
border-radius: var(--radius-field);
background-color: var(--color-bg);
color: var(--color-text-high);
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: var(--color-bg-high);
}
}
.selectedWeaponName {
font-weight: var(--weight-semi);
}
/** needs to go away so we have enough space even with both side panels open */
@media screen and (max-width: 1100px) {
.searchKbd {
display: none;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes zoom-in-95 {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -6,8 +6,6 @@ import {
Button,
Dialog,
DialogTrigger,
ListBox,
ListBoxItem,
Modal,
ModalOverlay,
Radio,
@@ -44,6 +42,13 @@ import {
GLOBAL_SEARCH_TYPES as SEARCH_TYPES,
type GlobalSearchType as SearchType,
} from "./global-search-search-params";
import {
SearchResultsEmptyState,
SearchResultsItem,
SearchResultsItemName,
SearchResultsItemRow,
SearchResultsListBox,
} from "./SearchResults";
import {
filterWeaponResults,
type SelectedWeapon,
@@ -354,44 +359,43 @@ function GlobalSearchContent({
listBoxRef={listBoxRef}
/>
) : (
<ListBox
<SearchResultsListBox
ref={listBoxRef}
className={clsx(styles.listBox, "scrollbar")}
className="scrollbar"
aria-label={t("common:search")}
onAction={handleSelect}
renderEmptyState={() => {
if (!hasQuery) {
return (
<div className={styles.emptyState}>
<SearchResultsEmptyState>
{t("common:search.hint")}
</div>
</SearchResultsEmptyState>
);
}
if (!isCurrentFetch) {
return (
<div className={styles.emptyState}>
<SearchResultsEmptyState>
{t("common:search.searching")}
</div>
</SearchResultsEmptyState>
);
}
return (
<div className={styles.emptyState}>
<SearchResultsEmptyState>
{t("common:search.noResults")}
</div>
</SearchResultsEmptyState>
);
}}
>
{results.map((result) => (
<ListBoxItem
<SearchResultsItem
key={getResultKey(result)}
id={getResultKey(result)}
href={getResultHref(result)}
className={styles.listBoxItem}
>
<ResultItem result={result} />
</ListBoxItem>
</SearchResultsItem>
))}
</ListBox>
</SearchResultsListBox>
)}
</div>
);
@@ -432,7 +436,7 @@ function ResultItem({ result }: { result: SearchResult }) {
switch (result.type) {
case "user":
return (
<div className={styles.resultItem}>
<SearchResultsItemRow>
<Avatar
user={{
discordId: result.discordId,
@@ -441,40 +445,40 @@ function ResultItem({ result }: { result: SearchResult }) {
size="xxs"
/>
<div className={styles.resultTexts}>
<span className={styles.resultName}>{result.name}</span>
<SearchResultsItemName>{result.name}</SearchResultsItemName>
{result.inGameName ? (
<span className={styles.resultSecondary}>
{result.inGameName}
</span>
) : null}
</div>
</div>
</SearchResultsItemRow>
);
case "team":
return (
<div className={styles.resultItem}>
<SearchResultsItemRow>
<Avatar
url={result.avatarUrl}
size="xxs"
identiconInput={result.name}
/>
<span className={styles.resultName}>{result.name}</span>
</div>
<SearchResultsItemName>{result.name}</SearchResultsItemName>
</SearchResultsItemRow>
);
case "organization":
return (
<div className={styles.resultItem}>
<SearchResultsItemRow>
<Avatar
url={result.avatarUrl}
size="xxs"
identiconInput={result.name}
/>
<span className={styles.resultName}>{result.name}</span>
</div>
<SearchResultsItemName>{result.name}</SearchResultsItemName>
</SearchResultsItemRow>
);
case "tournament":
return (
<div className={styles.resultItem}>
<SearchResultsItemRow>
{result.logoUrl ? (
<img
src={result.logoUrl}
@@ -485,14 +489,14 @@ function ResultItem({ result }: { result: SearchResult }) {
/>
) : null}
<div className={styles.resultTexts}>
<span className={styles.resultName}>{result.name}</span>
<SearchResultsItemName>{result.name}</SearchResultsItemName>
<LocaleTime
date={result.startsAt}
options={{ day: "numeric", month: "long", year: "numeric" }}
className={styles.resultSecondary}
/>
</div>
</div>
</SearchResultsItemRow>
);
}
}

View File

@@ -1,7 +1,10 @@
import clsx from "clsx";
import { Bell, ChevronRight } from "lucide-react";
import * as React from "react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SendouPopover } from "~/components/elements/Popover";
import {
NotificationItem,
NotificationItemDivider,
@@ -39,6 +42,34 @@ export function useNotifications() {
return { notifications, unseenIds, showUnseenDot };
}
export function NotificationPopover({
notifications,
unseenIds,
triggerClassName,
}: {
notifications: LoaderNotification[];
unseenIds: number[];
triggerClassName?: string;
}) {
return (
<SendouPopover
trigger={
<Button className={triggerClassName} data-testid="notifications-button">
<Bell />
</Button>
}
popoverClassName={clsx(styles.popoverContainer, {
[styles.noNotificationsContainer]: notifications.length === 0,
})}
>
<NotificationContent
notifications={notifications}
unseenIds={unseenIds}
/>
</SendouPopover>
);
}
export function NotificationContent({
notifications,
unseenIds,

View File

@@ -0,0 +1,48 @@
.listBox {
max-height: 325px;
overflow-y: auto;
padding: var(--s-2);
outline: none;
}
.listBoxItem {
display: block;
padding: var(--s-2) var(--s-3);
border-radius: var(--radius-field);
cursor: pointer;
font-size: var(--font-sm);
outline: none;
color: inherit;
text-decoration: none;
&[data-hovered] {
background-color: var(--color-bg-high);
}
&[data-focused] {
background-color: var(--color-bg-high);
}
&[data-selected] {
background-color: var(--color-accent-low);
}
}
.resultItem {
display: flex;
align-items: center;
gap: var(--s-2);
}
.resultName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
padding: var(--s-8);
text-align: center;
color: var(--color-text-high);
font-size: var(--font-sm);
}

View File

@@ -0,0 +1,57 @@
/**
* The result list chrome shared by the global search and its weapon sub-view:
* the list box, its items and the empty state shown while there is nothing to
* list.
*/
import clsx from "clsx";
import type * as React from "react";
import {
ListBox,
ListBoxItem,
type ListBoxItemProps,
type ListBoxProps,
} from "react-aria-components";
import styles from "./SearchResults.module.css";
export function SearchResultsListBox<T extends object>({
className,
ref,
children,
...rest
}: ListBoxProps<T> & { ref?: React.Ref<HTMLDivElement> }) {
return (
<ListBox {...rest} ref={ref} className={clsx(styles.listBox, className)}>
{children}
</ListBox>
);
}
export function SearchResultsEmptyState({
children,
}: {
children: React.ReactNode;
}) {
return <div className={styles.emptyState}>{children}</div>;
}
export function SearchResultsItem(props: ListBoxItemProps) {
return <ListBoxItem {...props} className={styles.listBoxItem} />;
}
/** the icon + texts row inside one result */
export function SearchResultsItemRow({
children,
}: {
children: React.ReactNode;
}) {
return <div className={styles.resultItem}>{children}</div>;
}
export function SearchResultsItemName({
children,
}: {
children: React.ReactNode;
}) {
return <span className={styles.resultName}>{children}</span>;
}

View File

@@ -0,0 +1,29 @@
.weaponDestinationHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-3) var(--s-4);
border-bottom: 1px solid var(--color-border);
}
.backButton {
display: flex;
align-items: center;
justify-content: center;
height: var(--field-size-sm);
aspect-ratio: 1 / 1;
border: 1px solid var(--color-border);
border-radius: var(--radius-field);
background-color: var(--color-bg);
color: var(--color-text-high);
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: var(--color-bg-high);
}
}
.selectedWeaponName {
font-weight: var(--weight-semi);
}

View File

@@ -1,4 +1,3 @@
import { clsx } from "clsx";
import type { Namespace, TFunction } from "i18next";
import {
Calculator,
@@ -12,7 +11,6 @@ import {
Videotape,
} from "lucide-react";
import type * as React from "react";
import { ListBox, ListBoxItem } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
@@ -33,7 +31,14 @@ import {
weaponBuildStatsPage,
weaponParamsPage,
} from "~/utils/urls";
import styles from "./GlobalSearch.module.css";
import {
SearchResultsEmptyState,
SearchResultsItem,
SearchResultsItemName,
SearchResultsItemRow,
SearchResultsListBox,
} from "./SearchResults";
import styles from "./WeaponSearch.module.css";
const WEAPON_DESTINATIONS = [
"builds",
@@ -154,104 +159,101 @@ export function WeaponDestinationMenu({
<Image path={mainWeaponImageUrl(selectedWeapon.id)} size={24} alt="" />
<span className={styles.selectedWeaponName}>{selectedWeapon.name}</span>
</div>
<ListBox
<SearchResultsListBox
ref={listBoxRef}
className={styles.listBox}
aria-label={selectedWeapon.name}
onAction={onSelect}
autoFocus="first"
>
<ListBoxItem
<SearchResultsItem
id="builds"
href={getWeaponDestinationUrl("builds", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SearchResultsItemRow>
<FlaskConical size={20} />
<span className={styles.resultName}>
<SearchResultsItemName>
{t("common:pages.builds")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
</SearchResultsItemName>
</SearchResultsItemRow>
</SearchResultsItem>
<SearchResultsItem
id="popular"
href={getWeaponDestinationUrl("popular", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SearchResultsItemRow>
<Flame size={20} />
<span className={styles.resultName}>
<SearchResultsItemName>
{t("common:pages.popularBuilds")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
</SearchResultsItemName>
</SearchResultsItemRow>
</SearchResultsItem>
<SearchResultsItem
id="stats"
href={getWeaponDestinationUrl("stats", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SearchResultsItemRow>
<ChartColumnBig size={20} />
<span className={styles.resultName}>
<SearchResultsItemName>
{t("common:pages.abilityStats")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
</SearchResultsItemName>
</SearchResultsItemRow>
</SearchResultsItem>
<SearchResultsItem
id="analyzer"
href={getWeaponDestinationUrl("analyzer", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SearchResultsItemRow>
<Calculator size={20} />
<span className={styles.resultName}>
<SearchResultsItemName>
{t("common:pages.analyzer")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
</SearchResultsItemName>
</SearchResultsItemRow>
</SearchResultsItem>
<SearchResultsItem
id="params"
href={getWeaponDestinationUrl("params", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SearchResultsItemRow>
<SlidersHorizontal size={20} />
<span className={styles.resultName}>
<SearchResultsItemName>
{t("common:pages.params")}
</span>
</div>
</ListBoxItem>
<ListBoxItem
</SearchResultsItemName>
</SearchResultsItemRow>
</SearchResultsItem>
<SearchResultsItem
id="vods"
href={getWeaponDestinationUrl("vods", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SearchResultsItemRow>
<Videotape size={20} />
<span className={styles.resultName}>{t("common:pages.vods")}</span>
</div>
</ListBoxItem>
<ListBoxItem
<SearchResultsItemName>
{t("common:pages.vods")}
</SearchResultsItemName>
</SearchResultsItemRow>
</SearchResultsItem>
<SearchResultsItem
id="art"
href={getWeaponDestinationUrl("art", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SearchResultsItemRow>
<ImageIcon size={20} />
<span className={styles.resultName}>{t("common:pages.art")}</span>
</div>
</ListBoxItem>
<ListBoxItem
<SearchResultsItemName>
{t("common:pages.art")}
</SearchResultsItemName>
</SearchResultsItemRow>
</SearchResultsItem>
<SearchResultsItem
id="lfg"
href={getWeaponDestinationUrl("lfg", selectedWeapon)}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SearchResultsItemRow>
<Users size={20} />
<span className={styles.resultName}>{t("common:pages.lfg")}</span>
</div>
</ListBoxItem>
</ListBox>
<SearchResultsItemName>
{t("common:pages.lfg")}
</SearchResultsItemName>
</SearchResultsItemRow>
</SearchResultsItem>
</SearchResultsListBox>
</div>
);
}
@@ -276,34 +278,35 @@ export function WeaponResultsList({
const showHint = !hasQuery && recentWeapons.length === 0;
return (
<ListBox
<SearchResultsListBox
ref={listBoxRef}
className={clsx(styles.listBox, "scrollbar")}
className="scrollbar"
aria-label={t("common:search")}
selectionMode="single"
onAction={onSelect}
renderEmptyState={() =>
showNoResults ? (
<div className={styles.emptyState}>
<SearchResultsEmptyState>
{t("common:search.noResults")}
</div>
</SearchResultsEmptyState>
) : showHint ? (
<div className={styles.emptyState}>{t("common:search.hint")}</div>
<SearchResultsEmptyState>
{t("common:search.hint")}
</SearchResultsEmptyState>
) : null
}
>
{displayedWeapons.map((weapon) => (
<ListBoxItem
<SearchResultsItem
key={`weapon-${weapon.id}`}
id={`weapon-${weapon.id}`}
className={styles.listBoxItem}
>
<div className={styles.resultItem}>
<SearchResultsItemRow>
<Image path={mainWeaponImageUrl(weapon.id)} size={24} alt="" />
<span className={styles.resultName}>{weapon.name}</span>
</div>
</ListBoxItem>
<SearchResultsItemName>{weapon.name}</SearchResultsItemName>
</SearchResultsItemRow>
</SearchResultsItem>
))}
</ListBox>
</SearchResultsListBox>
);
}

View File

@@ -334,3 +334,72 @@
z-index: 20;
}
}
.sideNavFooterUser {
display: flex;
align-items: center;
gap: var(--s-2);
color: var(--color-text);
text-decoration: none;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
border-radius: var(--radius-field);
padding: var(--s-1);
margin-left: calc(-1 * var(--s-1));
flex: 1;
min-width: 0;
&:hover {
background-color: var(--color-bg-high);
}
}
.sideNavFooterUsername {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sideNavFooterActions {
display: flex;
align-items: center;
gap: var(--s-0-5);
margin-left: auto;
}
.sideNavFooterButton {
display: grid;
place-items: center;
background-color: transparent;
width: var(--field-size-sm);
height: var(--field-size-sm);
padding: 0;
border: none;
border-radius: var(--radius-field);
color: var(--color-text-high);
cursor: pointer;
&:hover {
background-color: var(--color-bg-high);
color: var(--color-text);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: -2px;
}
& svg {
width: 18px;
height: 18px;
}
}
.sideNavFooterNotification {
position: relative;
}
.sideNavFooterUnseenDot {
--dot-top: 2px;
--dot-right: 2px;
}

View File

@@ -1,7 +1,6 @@
import clsx from "clsx";
import { isToday, isTomorrow } from "date-fns";
import {
Bell,
Calendar,
ChevronRight,
LogIn,
@@ -12,7 +11,6 @@ import {
} from "lucide-react";
import * as React from "react";
import {
Button,
Dialog,
DialogTrigger,
Modal,
@@ -44,21 +42,18 @@ import {
} from "~/utils/urls";
import { Avatar, generateIdenticon } from "../Avatar";
import { SendouButton } from "../elements/Button";
import { SendouPopover } from "../elements/Popover";
import { FuseZone } from "../fuse/Fuse";
import { Image } from "../Image";
import { MobileNav } from "../MobileNav";
import { NotificationDot } from "../NotificationDot";
import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav";
import sideNavStyles from "../SideNav.module.css";
import { StreamListItems } from "../StreamListItems";
import { ChatSidebar } from "./ChatSidebar";
import { Footer } from "./Footer";
import styles from "./index.module.css";
import { LogInButtonContainer } from "./LogInButtonContainer";
import { authErrorSearchParams } from "./layout-search-params";
import { NotificationContent, useNotifications } from "./NotificationPopover";
import notificationPopoverStyles from "./NotificationPopover.module.css";
import { NotificationPopover, useNotifications } from "./NotificationPopover";
import { TopNavMenus } from "./TopNavMenus";
import { TopRightButtons } from "./TopRightButtons";
@@ -676,52 +671,30 @@ function SideNavUserPanel() {
if (user) {
return (
<>
<Link to={userPage(user)} className={sideNavStyles.sideNavFooterUser}>
<Link to={userPage(user)} className={styles.sideNavFooterUser}>
<Avatar user={user} size="xs" />
<span className={sideNavStyles.sideNavFooterUsername}>
{user.username}
</span>
<span className={styles.sideNavFooterUsername}>{user.username}</span>
</Link>
<div className={sideNavStyles.sideNavFooterActions}>
<div className={styles.sideNavFooterActions}>
{notifications ? (
<div
className={sideNavStyles.sideNavFooterNotification}
className={styles.sideNavFooterNotification}
key={location.pathname}
>
{showUnseenDot ? (
<NotificationDot
className={sideNavStyles.sideNavFooterUnseenDot}
className={styles.sideNavFooterUnseenDot}
testId="notifications-bell-dot"
/>
) : null}
<SendouPopover
trigger={
<Button
className={sideNavStyles.sideNavFooterButton}
data-testid="notifications-button"
>
<Bell />
</Button>
}
popoverClassName={clsx(
notificationPopoverStyles.popoverContainer,
{
[notificationPopoverStyles.noNotificationsContainer]:
notifications.length === 0,
},
)}
>
<NotificationContent
notifications={notifications}
unseenIds={unseenIds}
/>
</SendouPopover>
<NotificationPopover
notifications={notifications}
unseenIds={unseenIds}
triggerClassName={styles.sideNavFooterButton}
/>
</div>
) : null}
<Link
to={SETTINGS_PAGE}
className={sideNavStyles.sideNavFooterButton}
>
<Link to={SETTINGS_PAGE} className={styles.sideNavFooterButton}>
<Settings />
</Link>
</div>
@@ -736,8 +709,8 @@ function SideNavUserPanel() {
{t("header.login.discord")}
</SendouButton>
</LogInButtonContainer>
<div className={sideNavStyles.sideNavFooterActions}>
<Link to={SETTINGS_PAGE} className={sideNavStyles.sideNavFooterButton}>
<div className={styles.sideNavFooterActions}>
<Link to={SETTINGS_PAGE} className={styles.sideNavFooterButton}>
<Settings />
</Link>
</div>

View File

@@ -214,3 +214,19 @@ function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
</SendouPopover>
);
}
/**
* Trigger for the small popovers hung off a banner's info row, e.g. who voted
* for the map or which team picked it.
*/
export function MatchBannerInfoBadge({
children,
}: {
children: React.ReactNode;
}) {
return (
<SendouButton variant="minimal" className={styles.infoBadge}>
{children}
</SendouButton>
);
}

View File

@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next";
import { useHydrated } from "~/hooks/useHydrated";
import styles from "./MatchBannerTopRow.module.css";
const MAX_MINUTES = 60;
@@ -35,8 +34,11 @@ export function MatchBannerTimer({ time }: MatchBannerTimerProps) {
: minuteFormatter.format(minutes);
return (
<div className={styles.values} data-testid="match-timer">
<time dateTime={dateTime(time.currentMinutes)} className={styles.sub}>
<div
className="stack horizontal sm font-semi-bold"
data-testid="match-timer"
>
<time dateTime={dateTime(time.currentMinutes)} className="text-lighter">
{displayValue(time.currentMinutes)}
</time>
<time dateTime={dateTime(time.totalMinutes)}>

View File

@@ -3,13 +3,3 @@
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);
}

View File

@@ -29,12 +29,12 @@ function Score({
const { t } = useTranslation(["q"]);
return (
<div className={styles.values}>
<div className="stack horizontal sm font-semi-bold">
<div>
{score.alpha}-{score.bravo}
</div>
<div
className={styles.sub}
className="text-lighter"
data-testid={score.isFinal ? "match-final" : undefined}
>
{score.isFinal

View File

@@ -1,108 +0,0 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
border-radius: var(--radius-box);
background-color: var(--color-bg-badge);
color: var(--color-text-high);
gap: var(--s-6);
padding-block: var(--s-2);
padding-inline: var(--s-3);
}
.smallBadges {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-2);
margin-block-start: var(--s-1);
}
.generalInfoTexts {
display: flex;
justify-content: space-between;
color: var(--color-text-high);
font-size: var(--font-xs);
padding-inline: var(--s-1);
}
.explanation {
color: var(--color-text-accent);
font-weight: var(--weight-semi);
text-align: center;
}
.managers {
color: var(--color-text-high);
font-size: var(--font-2xs);
text-align: center;
}
.ownersContainer {
height: 8rem;
overflow-y: auto;
}
.owners {
display: flex;
flex-wrap: wrap;
justify-content: center;
padding: 0;
font-size: var(--font-sm);
gap: var(--s-1-5);
& > li {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.1;
list-style: none;
}
}
.count {
color: var(--color-accent-high);
font-size: var(--font-xs);
}
.editUsersList {
display: flex;
flex-direction: column;
padding: 0;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
gap: var(--s-2);
padding-block-end: var(--s-3);
& > li {
display: flex;
align-items: center;
justify-content: space-between;
list-style: none;
}
}
.editNumberInput {
max-width: 5rem;
}
.editSmallHeader {
font-size: var(--font-md);
}
.editDifferences {
padding: 0;
font-size: var(--font-xs);
list-style: none;
& > li::before {
content: "-";
padding-inline-end: 5px;
}
}
.searchInput {
height: 40px !important;
margin: 0 auto;
font-size: var(--font-lg);
}

View File

@@ -0,0 +1,35 @@
.editUsersList {
display: flex;
flex-direction: column;
padding: 0;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
gap: var(--s-2);
padding-block-end: var(--s-3);
& > li {
display: flex;
align-items: center;
justify-content: space-between;
list-style: none;
}
}
.editNumberInput {
max-width: 5rem;
}
.editSmallHeader {
font-size: var(--font-md);
}
.editDifferences {
padding: 0;
font-size: var(--font-xs);
list-style: none;
& > li::before {
content: "-";
padding-inline-end: 5px;
}
}

View File

@@ -9,10 +9,10 @@ import { UserSearch } from "~/components/elements/UserSearch";
import type { Tables } from "~/db/tables";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { action } from "../actions/badges.$id.edit.server";
import styles from "../badges.module.css";
import { editBadgeActionSchema } from "../badges-schemas";
import type { BadgeDetailsLoaderData } from "../loaders/badges.$id.server";
import type { BadgeDetailsContext } from "./badges.$id";
import styles from "./badges.$id.edit.module.css";
export { action };

View File

@@ -0,0 +1,38 @@
.explanation {
color: var(--color-text-accent);
font-weight: var(--weight-semi);
text-align: center;
}
.managers {
color: var(--color-text-high);
font-size: var(--font-2xs);
text-align: center;
}
.ownersContainer {
height: 8rem;
overflow-y: auto;
}
.owners {
display: flex;
flex-wrap: wrap;
justify-content: center;
padding: 0;
font-size: var(--font-sm);
gap: var(--s-1-5);
& > li {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.1;
list-style: none;
}
}
.count {
color: var(--color-accent-high);
font-size: var(--font-xs);
}

View File

@@ -7,10 +7,9 @@ import { LinkButton } from "~/components/elements/Button";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import { badgeUrl, userPage } from "~/utils/urls";
import styles from "../badges.module.css";
import { badgeExplanationText } from "../badges-utils";
import { loader } from "../loaders/badges.$id.server";
import styles from "./badges.$id.module.css";
export { loader };

View File

@@ -0,0 +1,33 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
border-radius: var(--radius-box);
background-color: var(--color-bg-badge);
color: var(--color-text-high);
gap: var(--s-6);
padding-block: var(--s-2);
padding-inline: var(--s-3);
}
.smallBadges {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-2);
margin-block-start: var(--s-1);
}
.generalInfoTexts {
display: flex;
justify-content: space-between;
color: var(--color-text-high);
font-size: var(--font-xs);
padding-inline: var(--s-1);
}
.searchInput {
height: 40px !important;
margin: 0 auto;
font-size: var(--font-lg);
}

View File

@@ -16,7 +16,7 @@ import { type BadgesLoaderData, loader } from "../loaders/badges.server";
export { loader };
import styles from "../badges.module.css";
import styles from "./badges.module.css";
export const handle: SendouRouteHandle = {
i18n: "badges",
@@ -75,11 +75,7 @@ export default function BadgesPageLayout() {
<Divider smallText>{t("badges:own.divider")}</Divider>
<div className={styles.smallBadges}>
{ownBadges.map((badge) => (
<NavLink
className={styles.navLink}
key={badge.id}
to={String(badge.id)}
>
<NavLink key={badge.id} to={String(badge.id)}>
<Badge badge={badge} size={64} isAnimated={false} />
</NavLink>
))}
@@ -93,11 +89,7 @@ export default function BadgesPageLayout() {
<Divider smallText>{t("badges:other.divider")}</Divider>
) : null}
{otherBadges.map((badge) => (
<NavLink
className={styles.navLink}
key={badge.id}
to={String(badge.id)}
>
<NavLink key={badge.id} to={String(badge.id)}>
<Badge badge={badge} size={64} isAnimated={false} />
</NavLink>
))}

View File

@@ -0,0 +1,38 @@
.inkGridContainer {
overflow: auto;
min-width: min(100vw, 1100px);
}
.inkGrid {
display: grid;
grid-template-columns: repeat(41, 1fr);
}
.inkGridHorizontalAbility {
grid-column: span 41;
display: grid;
place-items: center;
padding-block-end: var(--s-2);
}
.inkGridAp {
display: grid;
place-items: center;
font-weight: var(--weight-semi);
font-size: var(--font-xs);
}
.inkGridApFocused {
color: var(--color-accent);
font-weight: var(--weight-bold);
text-decoration: underline;
}
.inkGridCell {
width: 25px;
font-size: var(--font-xs);
display: grid;
place-items: center;
background-color: var(--cell-color);
cursor: help;
}

View File

@@ -9,7 +9,7 @@ import { MAX_AP } from "../analyzer-constants";
import type { FullInkTankOption, SpecialWeaponParams } from "../analyzer-types";
import { fullInkTankOptions } from "../core/stats";
import { mainWeaponParams, weaponParams } from "../core/utils";
import styles from "../routes/analyzer.module.css";
import styles from "./PerInkTankGrid.module.css";
interface PerInkTankGridProps {
weaponSplId: MainWeaponId;

View File

@@ -299,42 +299,3 @@
--chart-width: calc(340px * 1.75);
}
}
.inkGridContainer {
overflow: auto;
min-width: min(100vw, 1100px);
}
.inkGrid {
display: grid;
grid-template-columns: repeat(41, 1fr);
}
.inkGridHorizontalAbility {
grid-column: span 41;
display: grid;
place-items: center;
padding-block-end: var(--s-2);
}
.inkGridAp {
display: grid;
place-items: center;
font-weight: var(--weight-semi);
font-size: var(--font-xs);
}
.inkGridApFocused {
color: var(--color-accent);
font-weight: var(--weight-bold);
text-decoration: underline;
}
.inkGridCell {
width: 25px;
font-size: var(--font-xs);
display: grid;
place-items: center;
background-color: var(--cell-color);
cursor: help;
}

View File

@@ -2,7 +2,6 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Ability } from "~/components/Ability";
import { Image, ModeImage, WeaponImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import type { Tables } from "~/db/tables";
import type { AbilityPoints } from "~/features/build-analyzer/analyzer-types";
import { buildToAbilityPoints } from "~/features/build-analyzer/core/ability-points";
@@ -18,11 +17,11 @@ import type {
import { gearImageUrl, navIconUrl, resolveAvatarUrl } from "~/utils/urls";
import styles from "./BuildGraphic.module.css";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicContainer,
GraphicDateSubtitle,
GraphicHeader,
GraphicTitle,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
const BUILD_GRAPHIC_WIDTH = 380;
@@ -81,20 +80,14 @@ export function BuildGraphic({
})}
identiconInput={owner.discordId}
titleRow={
<span className={graphicStyles.headerTitle}>
<GraphicTitle>
{owner.username}
{owner.plusTier ? (
<span className={styles.plusTier}> +{owner.plusTier}</span>
) : null}
</span>
}
subtitle={
<LocaleTime
date={build.updatedAt}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={graphicStyles.headerSubtitle}
/>
</GraphicTitle>
}
subtitle={<GraphicDateSubtitle date={build.updatedAt} />}
trailing={
build.modes && build.modes.length > 0
? build.modes.map((mode) => (

View File

@@ -100,17 +100,6 @@ same place as an auto margin would but leaves their text room to breathe.
gap: var(--s-2);
}
/*
:where() keeps these base component classes at zero specificity so single-class
overrides from other module files win regardless of which CSS chunk loads last
*/
:where(.box) {
padding: var(--s-2) var(--s-3);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
}
.boxLabel {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);

View File

@@ -4,12 +4,14 @@ import * as React from "react";
import { Avatar } from "~/components/Avatar";
import { Flag } from "~/components/Flag";
import { SpecialWeaponImage, WeaponImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { Placement } from "~/components/Placement";
import { weaponParams } from "~/features/build-analyzer/core/utils";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import styles from "./Graphic.module.css";
export const GRAPHIC_DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
const GRAPHIC_DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
@@ -106,6 +108,48 @@ export function GraphicHeader({
);
}
/** the graphic's headline text, sized to claim the header row's free space */
export function GraphicTitle({ children }: { children: React.ReactNode }) {
return <span className={styles.headerTitle}>{children}</span>;
}
export function GraphicDateSubtitle({ date }: { date: number | Date }) {
return (
<LocaleTime
date={date}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={styles.headerSubtitle}
/>
);
}
export function GraphicDateRangeSubtitle({
from,
to,
}: {
from: number | Date;
to: number | Date;
}) {
return (
<LocaleTimeRange
from={from}
to={to}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={styles.headerSubtitle}
/>
);
}
export function GraphicBoxLabel({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return <div className={clsx(styles.boxLabel, className)}>{children}</div>;
}
export function GraphicTeamsList({ children }: { children: React.ReactNode }) {
return <ol className={styles.teamsList}>{children}</ol>;
}

View File

@@ -1,3 +1,14 @@
/*
:where() keeps the box base at zero specificity so the single-class variants
below win regardless of which CSS chunk loads last
*/
:where(.box) {
padding: var(--s-2) var(--s-3);
background-color: var(--graphic-row-bg);
border: 1.5px solid var(--graphic-row-border);
border-radius: var(--radius-box);
}
.seasonBadge {
font-size: var(--font-md);
font-weight: var(--weight-extra);

View File

@@ -11,7 +11,6 @@ import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Flag } from "~/components/Flag";
import { TierImage, WeaponImage } from "~/components/Image";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
import { StageBannerBox } from "~/components/StageBannerBox";
import { TierPill } from "~/components/TierPill";
import type { TierName } from "~/features/mmr/mmr-constants";
@@ -19,8 +18,9 @@ import { userSeasonsPage } from "~/features/user-page/user-page-urls";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicBoxLabel,
GraphicContainer,
GraphicDateRangeSubtitle,
GraphicFooter,
GraphicHeader,
GraphicPlacementCell,
@@ -32,9 +32,9 @@ import {
GraphicSiteUrl,
GraphicStat,
GraphicStatsRow,
GraphicTitle,
GraphicWonLost,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
import styles from "./SeasonSummaryGraphic.module.css";
const CHART_WIDTH = 672;
@@ -160,15 +160,13 @@ export function SeasonSummaryGraphic({
{user.countryCode ? (
<Flag countryCode={user.countryCode} tiny />
) : null}
<span className={graphicStyles.headerTitle}>{user.name}</span>
<GraphicTitle>{user.name}</GraphicTitle>
</>
}
subtitle={
<LocaleTimeRange
<GraphicDateRangeSubtitle
from={seasonDateRange.starts}
to={seasonDateRange.ends}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={graphicStyles.headerSubtitle}
/>
}
trailing={
@@ -177,7 +175,7 @@ export function SeasonSummaryGraphic({
</div>
}
/>
<div className={clsx(graphicStyles.box, styles.hero)}>
<SummaryBox className={styles.hero}>
<TierImage tier={tier} width={92} />
<div>
<div className={styles.heroTierName}>
@@ -194,14 +192,14 @@ export function SeasonSummaryGraphic({
{typeof soloRank === "number" ? (
<div className={styles.rankBlock}>
<div className={styles.rankValue}>#{soloRank}</div>
<div className={graphicStyles.boxLabel}>
<GraphicBoxLabel>
{t("user:seasons.summary.soloRank")}
</div>
</GraphicBoxLabel>
</div>
) : null}
</div>
</SummaryBox>
{teamRank ? (
<div className={clsx(graphicStyles.box, styles.teamRankRow)}>
<SummaryBox className={styles.teamRankRow}>
{teamRank.team ? (
<Avatar
url={teamRank.team.logoUrl}
@@ -236,12 +234,12 @@ export function SeasonSummaryGraphic({
{typeof teamRank.rank === "number" ? (
<div className={styles.rankBlock}>
<div className={styles.rankValue}>#{teamRank.rank}</div>
<div className={graphicStyles.boxLabel}>
<GraphicBoxLabel>
{t("user:seasons.summary.teamRank")}
</div>
</GraphicBoxLabel>
</div>
) : null}
</div>
</SummaryBox>
) : null}
<GraphicStatsRow>
<GraphicStat label={t("user:seasons.summary.sets")}>
@@ -260,18 +258,18 @@ export function SeasonSummaryGraphic({
</GraphicStat>
</GraphicStatsRow>
{spProgression.length >= CHART_POINTS_NEEDED ? (
<div className={graphicStyles.box}>
<SummaryBox>
<SpChart points={spProgression} />
</div>
</SummaryBox>
) : null}
{bestStage ? (
<StageBannerBox
stageId={bestStage.stageId}
className={clsx(graphicStyles.box, styles.bestStageRow)}
className={clsx(styles.box, styles.bestStageRow)}
>
<div className={graphicStyles.boxLabel}>
<GraphicBoxLabel>
{t("user:seasons.summary.bestStage")}
</div>
</GraphicBoxLabel>
<div className={styles.bestStageName}>
{t(`game-misc:STAGE_${bestStage.stageId}`)}{" "}
<span className={styles.bestStageWinrate}>
@@ -281,22 +279,22 @@ export function SeasonSummaryGraphic({
</StageBannerBox>
) : null}
<div className={styles.middleGrid}>
<div className={clsx(graphicStyles.box, styles.activityBox)}>
<div className={graphicStyles.boxLabel}>
<SummaryBox className={styles.activityBox}>
<GraphicBoxLabel>
{t("user:seasons.summary.activity")}
</div>
</GraphicBoxLabel>
<ActivityCalendar
seasonDateRange={seasonDateRange}
activeDays={activeDays}
/>
<ActivityLegend />
</div>
</SummaryBox>
<div className={styles.sideStack}>
{topWeapons.length > 0 ? (
<div className={graphicStyles.box}>
<div className={graphicStyles.boxLabel}>
<SummaryBox>
<GraphicBoxLabel>
{t("user:seasons.summary.topWeapons")}
</div>
</GraphicBoxLabel>
<div className={styles.weaponsRow}>
{topWeapons.map((weapon) => (
<div key={weapon.weaponSplId} className={styles.weaponUsage}>
@@ -305,23 +303,23 @@ export function SeasonSummaryGraphic({
variant="badge"
size={52}
/>
<div className={graphicStyles.boxLabel}>
<GraphicBoxLabel>
{Math.round(weapon.usagePercentage)}%
</div>
</GraphicBoxLabel>
</div>
))}
</div>
</div>
</SummaryBox>
) : null}
{shownMates.length > 0 ? (
<div
className={clsx(graphicStyles.box, {
<SummaryBox
className={clsx({
[styles.matesBoxExpanded]: topWeapons.length === 0,
})}
>
<div className={graphicStyles.boxLabel}>
<GraphicBoxLabel>
{t("user:seasons.summary.topMates")}
</div>
</GraphicBoxLabel>
<div className={styles.matesList}>
{shownMates.map((mate) => (
<div key={mate.player.name} className={styles.mateRow}>
@@ -339,17 +337,15 @@ export function SeasonSummaryGraphic({
{mate.player.name}
</span>
</div>
<div
className={clsx(graphicStyles.boxLabel, styles.mateSets)}
>
<GraphicBoxLabel className={styles.mateSets}>
{t("user:seasons.summary.count.sets", {
count: mate.setsCount,
})}
</div>
</GraphicBoxLabel>
</div>
))}
</div>
</div>
</SummaryBox>
) : null}
</div>
</div>
@@ -362,18 +358,16 @@ export function SeasonSummaryGraphic({
{bestSets.map((set, index) => (
<li
key={`${index}-${set.context}`}
className={clsx(graphicStyles.box, styles.bestSetRow)}
className={clsx(styles.box, styles.bestSetRow)}
>
<GraphicScore
ownScore={set.ownScore}
opponentScore={set.opponentScore}
/>
<div className={styles.setInfo}>
<div
className={clsx(graphicStyles.boxLabel, styles.setContext)}
>
<GraphicBoxLabel className={styles.setContext}>
{set.context}
</div>
</GraphicBoxLabel>
<div
className={clsx(
styles.playersInline,
@@ -389,9 +383,9 @@ export function SeasonSummaryGraphic({
<div className={styles.setSpValue}>
{set.opponentSp.toFixed(1)}
</div>
<div className={graphicStyles.boxLabel}>
<GraphicBoxLabel>
{t("user:seasons.summary.opponentSp")}
</div>
</GraphicBoxLabel>
</div>
</li>
))}
@@ -403,7 +397,7 @@ export function SeasonSummaryGraphic({
<GraphicSectionDivider>
{t("user:seasons.summary.bestTournament")}
</GraphicSectionDivider>
<div className={clsx(graphicStyles.box, styles.tournamentRow)}>
<SummaryBox className={styles.tournamentRow}>
<GraphicPlacementCell placement={bestTournament.placement} />
<Avatar
url={bestTournament.logoUrl}
@@ -422,7 +416,7 @@ export function SeasonSummaryGraphic({
{typeof bestTournament.tier === "number" ? (
<TierPill tier={bestTournament.tier} withoutAnimation />
) : null}
</div>
</SummaryBox>
</>
) : null}
{qrCodeUrl ? null : (
@@ -573,7 +567,7 @@ function ActivityLegend() {
const { t } = useTranslation(["user"]);
return (
<div className={clsx(styles.calendarLegend, graphicStyles.boxLabel)}>
<GraphicBoxLabel className={styles.calendarLegend}>
<div className={styles.calendarLegendItem}>
<div className={clsx(styles.calendarCell, styles.calendarSq)} />
SendouQ
@@ -586,7 +580,7 @@ function ActivityLegend() {
<div className={clsx(styles.calendarCell, styles.calendarBoth)} />
{t("user:seasons.summary.activity.both")}
</div>
</div>
</GraphicBoxLabel>
);
}
@@ -602,3 +596,13 @@ function activityClass(activity?: SeasonSummaryGraphicActivity) {
return styles.calendarBoth;
}
}
function SummaryBox({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return <div className={clsx(styles.box, className)}>{children}</div>;
}

View File

@@ -1,11 +1,10 @@
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { LocaleTime } from "~/components/LocaleTime";
import { TierPill } from "~/components/TierPill";
import { tournamentPage } from "~/utils/urls";
import {
GRAPHIC_DATE_FORMAT_OPTIONS,
GraphicContainer,
GraphicDateSubtitle,
GraphicFooter,
GraphicHeader,
GraphicPlacementCell,
@@ -13,8 +12,8 @@ import {
type GraphicTeam,
GraphicTeamRow,
GraphicTeamsList,
GraphicTitle,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
import styles from "./TournamentResultsGraphic.module.css";
export interface TournamentResultsGraphicTeam extends GraphicTeam {
@@ -88,22 +87,16 @@ export function TournamentGraphicHeader({
avatarUrl={logoUrl}
identiconInput={tournamentName}
titleRow={
<span className={graphicStyles.headerTitle}>
<GraphicTitle>
{tournamentName}
{typeof tier === "number" ? (
<span className={styles.tierPillContainer}>
<TierPill tier={tier} withoutAnimation />
</span>
) : null}
</span>
}
subtitle={
<LocaleTime
date={startTime}
options={GRAPHIC_DATE_FORMAT_OPTIONS}
className={graphicStyles.headerSubtitle}
/>
</GraphicTitle>
}
subtitle={<GraphicDateSubtitle date={startTime} />}
trailing={
organization ? (
<>

View File

@@ -1,4 +1,3 @@
import clsx from "clsx";
import { ArrowDown } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -6,6 +5,7 @@ import * as R from "remeda";
import { LocaleTime } from "~/components/LocaleTime";
import { tournamentTeamPage } from "~/utils/urls";
import {
GraphicBoxLabel,
GraphicContainer,
GraphicPlacementCell,
GraphicScore,
@@ -17,7 +17,6 @@ import {
GraphicTeamsList,
GraphicWonLost,
} from "./Graphic";
import graphicStyles from "./Graphic.module.css";
import {
TournamentGraphicFooter,
TournamentGraphicHeader,
@@ -152,13 +151,11 @@ export function TournamentRunGraphic({
team={match.opponent}
leading={
<div className={styles.matchLeading}>
<div
className={clsx(graphicStyles.boxLabel, styles.roundName)}
>
<GraphicBoxLabel className={styles.roundName}>
{roundNameLines(match.roundName).map((line) => (
<div key={line}>{line}</div>
))}
</div>
</GraphicBoxLabel>
<GraphicScore
ownScore={match.ownScore}
opponentScore={match.opponentScore}

View File

@@ -5,3 +5,36 @@
color: var(--color-text-high);
}
}
.tableName {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 117px;
}
.tierHeader {
display: flex;
align-items: center;
gap: var(--s-2);
margin-block: var(--s-2);
color: var(--color-text-high);
}
.tableRowQualification {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
justify-content: center;
background-color: var(--color-bg-higher);
display: flex;
gap: var(--s-2);
}
.tablePower {
margin-inline-start: auto;
}
.avatar {
min-width: 24px;
min-height: 24px;
}

View File

@@ -17,7 +17,7 @@ import {
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image";
import { Image, ModeImage, TierImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SeasonSelect } from "~/features/mmr/components/SeasonSelect";
@@ -54,9 +54,15 @@ import { loader } from "../loaders/leaderboards.server";
export { action, loader };
import {
RankTable,
RankTableInnerRow,
RankTableRank,
RankTableRow,
RankTableWeaponImage,
} from "~/components/RankTable";
import { topSearchPlayerPage } from "~/features/top-search/top-search-urls";
import { userSeasonsPage } from "~/features/user-page/user-page-urls";
import styles from "../../top-search/top-search.module.css";
import leaderboardsStyles from "./leaderboards.module.css";
export const handle: SendouRouteHandle = {
@@ -351,35 +357,28 @@ function OwnEntryPeek({
return (
<div>
{entry.firstOfTier ? (
<div className={styles.tierHeader}>
<div className={leaderboardsStyles.tierHeader}>
<TierImage tier={entry.firstOfTier} width={32} />
{entry.firstOfTier.name}
{entry.firstOfTier.isPlus ? "+" : ""}
</div>
) : null}
<div>
<Link
<RankTableRow
to={userSeasonsPage({ user: entry, season: data.season })}
className={styles.tableRow}
>
<div className={styles.tableInnerRow}>
<div className={styles.tableRank}>{entry.placementRank}</div>
<RankTableInnerRow>
<RankTableRank>{entry.placementRank}</RankTableRank>
<div>
<Avatar size="xxs" user={entry} />
</div>
{typeof entry.weaponSplId === "number" ? (
<WeaponImage
className={styles.tableWeapon}
variant="build"
weaponSplId={entry.weaponSplId}
width={32}
height={32}
/>
<RankTableWeaponImage weaponSplId={entry.weaponSplId} />
) : null}
<div className={styles.tableName}>{entry.username}</div>
<div className={styles.tablePower}>{entry.power}</div>
</div>
</Link>
<div className={leaderboardsStyles.tableName}>{entry.username}</div>
<div className={leaderboardsStyles.tablePower}>{entry.power}</div>
</RankTableInnerRow>
</RankTableRow>
</div>
{nextTier ? (
<div className="text-xs text-lighter ml-auto stack items-end">
@@ -404,7 +403,7 @@ function PlayersTable({
const data = useLoaderData<typeof loader>();
return (
<div className={styles.table}>
<RankTable>
{entries
// hide normal rows that are showed in "fancy" top 10 format
.filter((_, i) => !showingTopTen || i > 9)
@@ -412,45 +411,40 @@ function PlayersTable({
return (
<React.Fragment key={entry.entryId}>
{entry.firstOfTier && showTiers ? (
<div className={styles.tierHeader}>
<div className={leaderboardsStyles.tierHeader}>
<TierImage tier={entry.firstOfTier} width={32} />
{entry.firstOfTier.name}
{entry.firstOfTier.isPlus ? "+" : ""}
</div>
) : null}
<Link
<RankTableRow
to={userSeasonsPage({ user: entry, season: data.season })}
className={styles.tableRow}
>
<div className={styles.tableInnerRow}>
<div className={styles.tableRank}>{entry.placementRank}</div>
<RankTableInnerRow>
<RankTableRank>{entry.placementRank}</RankTableRank>
<div>
<Avatar size="xxs" user={entry} />
</div>
{typeof entry.weaponSplId === "number" ? (
<WeaponImage
className={styles.tableWeapon}
variant="build"
weaponSplId={entry.weaponSplId}
width={32}
height={32}
/>
<RankTableWeaponImage weaponSplId={entry.weaponSplId} />
) : null}
<div className={styles.tableName}>{entry.username}</div>
<div className={leaderboardsStyles.tableName}>
{entry.username}
</div>
{entry.pendingPlusTier ? (
<div className="text-xs text-theme whitespace-nowrap">
+{entry.pendingPlusTier}
</div>
) : null}
<div className={styles.tablePower}>
<div className={leaderboardsStyles.tablePower}>
{entry.power.toFixed(2)}
</div>
</div>
</Link>
</RankTableInnerRow>
</RankTableRow>
</React.Fragment>
);
})}
</div>
</RankTable>
);
}
@@ -471,13 +465,13 @@ function TeamTable({
_showQualificationDividers && isCurrentSeason && entries.length > 20;
return (
<div className={styles.table}>
<RankTable>
{entries.map((entry) => {
return (
<React.Fragment key={entry.entryId}>
<div className={styles.tableRow}>
<div className={styles.tableInnerRow}>
<div className={styles.tableRank}>{entry.placementRank}</div>
<RankTableRow>
<RankTableInnerRow>
<RankTableRank>{entry.placementRank}</RankTableRank>
{entry.team?.avatarUrl ? (
<Link
to={teamPage(entry.team.customUrl)}
@@ -486,7 +480,7 @@ function TeamTable({
<Avatar
size="xxs"
url={entry.team.avatarUrl}
className={styles.avatar}
className={leaderboardsStyles.avatar}
/>
</Link>
) : null}
@@ -504,27 +498,27 @@ function TeamTable({
);
})}
</div>
<div className={styles.tablePower}>
<div className={leaderboardsStyles.tablePower}>
{entry.power.toFixed(2)}
</div>
{showStaffActions ? <TeamStaffMenu entry={entry} /> : null}
</div>
</div>
</RankTableInnerRow>
</RankTableRow>
{entry.placementRank === TEAM_LEADERBOARD_QUALIFYING_COUNT &&
showQualificationDividers ? (
<div
className={`${styles.tableRow} ${styles.tableRowQualification}`}
<RankTableRow
className={leaderboardsStyles.tableRowQualification}
>
{t("common:leaderboard.qualification")}
<InfoPopover tiny>
{t("common:leaderboard.qualification.info")}
</InfoPopover>
</div>
</RankTableRow>
) : null}
</React.Fragment>
);
})}
</div>
</RankTable>
);
}
@@ -573,32 +567,27 @@ function TeamStaffMenu({
function XPTable({ entries }: { entries: XPLeaderboardItem[] }) {
return (
<div className={styles.table}>
<RankTable>
{entries.map((entry) => {
return (
<Link
<RankTableRow
to={topSearchPlayerPage(entry.playerId)}
key={entry.entryId}
className={styles.tableRow}
>
<div className={styles.tableInnerRow}>
<div className={styles.tableRank}>{entry.placementRank}</div>
<RankTableInnerRow>
<RankTableRank>{entry.placementRank}</RankTableRank>
{entry.discordId ? (
<Avatar size="xxs" user={entry as any} />
) : null}
<WeaponImage
className={styles.tableWeapon}
variant="build"
weaponSplId={entry.weaponSplId}
width={32}
height={32}
/>
<RankTableWeaponImage weaponSplId={entry.weaponSplId} />
<div>{entry.name}</div>
<div className={styles.tablePower}>{entry.power.toFixed(1)}</div>
</div>
</Link>
<div className={leaderboardsStyles.tablePower}>
{entry.power.toFixed(1)}
</div>
</RankTableInnerRow>
</RankTableRow>
);
})}
</div>
</RankTable>
);
}

View File

@@ -80,63 +80,3 @@
.deleteButton {
display: inline;
}
.votingContainer {
max-width: 32rem;
margin-inline: auto;
}
.votingUserTrigger {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
}
.votingVoteButton {
width: 4rem;
}
.votingVoteButtonDownvote {
border-color: var(--color-error);
color: var(--color-error);
outline-color: var(--color-error);
}
.votingSubmitButton {
display: flex;
width: 12rem;
height: 2.5rem;
margin-block-start: var(--s-6);
margin-inline: auto;
}
.votingProgress {
width: 100%;
}
.votingAlert {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-box);
background-color: var(--color-success-high);
color: var(--color-text);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
gap: var(--s-2);
line-height: 0.75;
margin-inline: auto;
padding-block: var(--s-1);
padding-inline: var(--s-3) var(--s-4);
& > svg {
height: 1.75rem;
fill: var(--color-success);
}
}
.votingBioHeader {
font-size: var(--font-lg);
padding-block-end: var(--s-2);
}

View File

@@ -29,7 +29,6 @@ import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags, type SerializeFrom } from "~/utils/remix";
import { action } from "../actions/plus.suggestions.server";
import { loader } from "../loaders/plus.suggestions.server";
import styles from "../plus.module.css";
import type { PlusTier } from "../plus-suggestions-constants";
import { editSuggestionFormSchema } from "../plus-suggestions-schemas";
import {
@@ -41,6 +40,7 @@ import {
canAddCommentToSuggestionFE,
canSuggestNewUser,
} from "../plus-suggestions-utils";
import styles from "./plus.suggestions.module.css";
export { action, loader };

View File

@@ -1,12 +1,10 @@
import { Outlet } from "react-router";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { plusSuggestionPage } from "~/features/plus-suggestions/plus-suggestions-urls";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl } from "~/utils/urls";
import "../plus.module.css";
import { plusSuggestionPage } from "~/features/plus-suggestions/plus-suggestions-urls";
export const handle: SendouRouteHandle = {
navItemName: "plus",
breadcrumb: () => ({

View File

@@ -0,0 +1,59 @@
.votingContainer {
max-width: 32rem;
margin-inline: auto;
}
.votingUserTrigger {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
}
.votingVoteButton {
width: 4rem;
}
.votingVoteButtonDownvote {
border-color: var(--color-error);
color: var(--color-error);
outline-color: var(--color-error);
}
.votingSubmitButton {
display: flex;
width: 12rem;
height: 2.5rem;
margin-block-start: var(--s-6);
margin-inline: auto;
}
.votingProgress {
width: 100%;
}
.votingAlert {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-box);
background-color: var(--color-success-high);
color: var(--color-text);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
gap: var(--s-2);
line-height: 0.75;
margin-inline: auto;
padding-block: var(--s-1);
padding-inline: var(--s-3) var(--s-4);
& > svg {
height: 1.75rem;
fill: var(--color-success);
}
}
.votingBioHeader {
font-size: var(--font-lg);
padding-block-end: var(--s-2);
}

View File

@@ -6,18 +6,17 @@ import { Form, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { RelativeTime } from "~/components/RelativeTime";
import styles from "~/features/plus-suggestions/plus.module.css";
import { usePlusVoting } from "~/features/plus-voting/core";
import { UserCard } from "~/features/user-card/components/UserCard";
import { metaTags } from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import { PlusSuggestionComments } from "../../plus-suggestions/routes/plus.suggestions";
import { action } from "../actions/plus.voting.server";
import {
loader,
type PlusVotingLoaderData,
} from "../loaders/plus.voting.server";
import styles from "./plus.voting.module.css";
export { action, loader };

View File

@@ -8,7 +8,7 @@ import { Ability } from "~/components/Ability";
import { SendouPopover } from "~/components/elements/Popover";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
import styles from "./AbilityGrid.module.css";
import eventCardStyles from "./EventCard.module.css";
import { EventCardPlayerTable } from "./EventCardShell";
const ROW_LABELS = ["head", "clothes", "shoes"] as const;
@@ -18,20 +18,18 @@ export function AbilityGrid({
abilities: AbilityWithUnknown[][];
}) {
return (
<table className={eventCardStyles.players}>
<tbody>
{abilities.map((row, i) => (
<tr key={i}>
<td>{ROW_LABELS[i]}</td>
{row.map((id, j) => (
<td key={j}>
<Ability ability={id} size={j === 0 ? "SUBTINY" : "TINY"} />
</td>
))}
</tr>
))}
</tbody>
</table>
<EventCardPlayerTable>
{abilities.map((row, i) => (
<tr key={i}>
<td>{ROW_LABELS[i]}</td>
{row.map((id, j) => (
<td key={j}>
<Ability ability={id} size={j === 0 ? "SUBTINY" : "TINY"} />
</td>
))}
</tr>
))}
</EventCardPlayerTable>
);
}

View File

@@ -1,13 +1,17 @@
import clsx from "clsx";
import { Ability } from "~/components/Ability";
import { WeaponImage } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import styles from "./DeathCard.module.css";
import eventCardStyles from "./EventCard.module.css";
import {
EventCardMeta,
EventCardShell,
EventCardTeam,
EventCardTeams,
EventCardWeaponIcon,
} from "./EventCardShell";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { weaponLabel } from "./labels";
@@ -28,8 +32,8 @@ export function DeathCard(props: {
const weaponName = weaponLabel(data.weaponType, data.weaponId);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<EventCardShell>
<EventCardMeta>
<MetaPills
t={t}
confidence={confidence}
@@ -43,16 +47,13 @@ export function DeathCard(props: {
onInspect={onInspect}
fixture={{ data, type: "Death" }}
/>
</div>
<div className={clsx(eventCardStyles.teams, eventCardStyles.death)}>
<div className={eventCardStyles.team}>
</EventCardMeta>
<EventCardTeams layout="death">
<EventCardTeam>
<div className={styles.body}>
{data.weaponId !== null && data.weaponType === "MAIN" ? (
<WeaponImage
<EventCardWeaponIcon
weaponSplId={data.weaponId as MainWeaponId}
variant="build"
size={28}
className={eventCardStyles.weaponIcon}
/>
) : null}
<div className={styles.info}>
@@ -75,8 +76,8 @@ export function DeathCard(props: {
))}
</div>
</div>
</div>
</div>
</div>
</EventCardTeam>
</EventCardTeams>
</EventCardShell>
);
}

View File

@@ -1,106 +1,3 @@
/* card chrome shared by every detected-event card, plus the sendou.ink send
status strip the feed pages hang under one */
.card {
background: var(--color-bg-high);
border-radius: var(--radius-box);
/* MatchCard's nested event list tightens this */
padding: var(--scanner-card-padding, var(--s-4));
min-width: 0;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--s-2) var(--s-4);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
font-variant-numeric: tabular-nums;
margin-bottom: var(--s-2);
align-items: center;
}
.teams {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: var(--s-2);
}
/* single hug-content box (own-results cards) */
.solo {
grid-template-columns: max-content;
}
/* single full-width box (death cards) */
.death {
grid-template-columns: 1fr;
}
/* lighter than a solid slab: translucent fill + hairline so the boxes read
as grouping, not chrome */
.team {
border-radius: var(--radius-field);
padding: var(--s-2) var(--s-2-5);
background: color-mix(in oklab, var(--color-bg) 45%, transparent);
border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent);
min-width: 0;
overflow-x: auto;
& h3 {
margin: 0 0 var(--s-1-5);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-high);
}
&.win h3 {
color: var(--color-error-high);
}
&.lose h3 {
color: var(--color-info-high);
}
}
.players {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
& td {
padding: var(--s-0-5) var(--s-1-5);
white-space: nowrap;
&.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
}
.solo .players {
width: auto;
}
.weaponCell {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
/* round black pucks, same language as the match card's weapon row */
.weaponIcon {
width: 28px;
height: 28px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
/* sendou.ink send status strip under a feed card */
.sendWrap {
display: flex;

View File

@@ -102,6 +102,15 @@ export function EventCard(props: {
);
}
/** `unlinked` has no strip styling of its own */
const SEND_STATE_CLASS: Record<SendStatus["state"], string | undefined> = {
queued: styles.queued,
sending: styles.sending,
sent: styles.sent,
unlinked: undefined,
failed: styles.failed,
};
const SEND_LABELS: Record<SendStatus["state"], string> = {
queued: "queued",
sending: "sending…",
@@ -120,7 +129,9 @@ function SendStrip({
const state = send?.state;
const formatSentAt = useEventTimeFormatter();
return (
<div className={clsx(styles.sendStrip, state ? styles[state] : null)}>
<div
className={clsx(styles.sendStrip, state ? SEND_STATE_CLASS[state] : null)}
>
<span>
sendou.ink: {state ? SEND_LABELS[state] : "not sent"}
{state === "sent" && send ? ` ${formatSentAt(send.at)}` : null}

View File

@@ -0,0 +1,101 @@
/* card chrome every detected-event card is built from */
.card {
background: var(--color-bg-high);
border-radius: var(--radius-box);
/* MatchCard's nested event list tightens this */
padding: var(--scanner-card-padding, var(--s-4));
min-width: 0;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--s-2) var(--s-4);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
font-variant-numeric: tabular-nums;
margin-bottom: var(--s-2);
align-items: center;
}
.teams {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: var(--s-2);
}
/* single hug-content box (own-results cards) */
.solo {
grid-template-columns: max-content;
}
/* single full-width box (death cards) */
.death {
grid-template-columns: 1fr;
}
/* lighter than a solid slab: translucent fill + hairline so the boxes read
as grouping, not chrome */
.team {
border-radius: var(--radius-field);
padding: var(--s-2) var(--s-2-5);
background: color-mix(in oklab, var(--color-bg) 45%, transparent);
border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent);
min-width: 0;
overflow-x: auto;
& h3 {
margin: 0 0 var(--s-1-5);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-high);
}
&.win h3 {
color: var(--color-error-high);
}
&.lose h3 {
color: var(--color-info-high);
}
}
.players {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
& td {
padding: var(--s-0-5) var(--s-1-5);
white-space: nowrap;
&.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
}
.solo .players {
width: auto;
}
.weaponCell {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
/* round black pucks, same language as the match card's weapon row */
.weaponIcon {
width: 28px;
height: 28px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}

View File

@@ -0,0 +1,101 @@
/**
* Chrome every detected-event card is built from: the card box with its meta
* row on top, and the team boxes / player tables the richer cards fill in.
*/
import clsx from "clsx";
import type * as React from "react";
import { WeaponImage } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import styles from "./EventCardShell.module.css";
export function EventCardShell({ children }: { children: React.ReactNode }) {
return <div className={styles.card}>{children}</div>;
}
/** top row of a card: the event pills, a summary line and the frame thumb */
export function EventCardMeta({ children }: { children: React.ReactNode }) {
return <div className={styles.meta}>{children}</div>;
}
export function EventCardTeams({
layout,
children,
}: {
/** `solo` hugs a single box, `death` gives one full width box */
layout?: "solo" | "death";
children: React.ReactNode;
}) {
return (
<div
className={clsx(styles.teams, {
[styles.solo]: layout === "solo",
[styles.death]: layout === "death",
})}
>
{children}
</div>
);
}
export function EventCardTeam({
result,
children,
}: {
result?: "win" | "lose";
children: React.ReactNode;
}) {
return (
<div
className={clsx(styles.team, {
[styles.win]: result === "win",
[styles.lose]: result === "lose",
})}
>
{children}
</div>
);
}
export function EventCardPlayerTable({
children,
}: {
children: React.ReactNode;
}) {
return (
<table className={styles.players}>
<tbody>{children}</tbody>
</table>
);
}
export function EventCardNumberCell({
children,
}: {
children: React.ReactNode;
}) {
return <td className={styles.num}>{children}</td>;
}
export function EventCardWeaponCell({
children,
}: {
children: React.ReactNode;
}) {
return <span className={styles.weaponCell}>{children}</span>;
}
export function EventCardWeaponIcon({
weaponSplId,
}: {
weaponSplId: MainWeaponId;
}) {
return (
<WeaponImage
weaponSplId={weaponSplId}
variant="build"
size={28}
className={styles.weaponIcon}
/>
);
}

View File

@@ -32,3 +32,23 @@
opacity: 0.5;
}
}
.preview {
width: 100%;
background: #000;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
@container scanner (width < 700px) {
/* stacked, the preview would otherwise push the feed off the screen */
.preview {
max-height: 45vh;
object-fit: contain;
}
}
button.outlined {
background: transparent;
color: var(--color-text-accent);
}

View File

@@ -1,7 +1,5 @@
import clsx from "clsx";
import { Camera, Ellipsis, FileText, Send, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { GameTimeline } from "~/components/GameTimeline";
import {
@@ -28,7 +26,6 @@ import {
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import scannerStyles from "../scanner.module.css";
import {
clearEvents,
deleteEvents,
@@ -48,6 +45,13 @@ import styles from "./LivePage.module.css";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { playerStatusTeams } from "./player-status-view";
import {
ScannerControls,
ScannerFeed,
ScannerMenuButton,
ScannerSplitLayout,
ScannerStatusPill,
} from "./ScannerChrome";
import {
aggregateSendStatus,
matchContaining,
@@ -310,7 +314,7 @@ export function LivePage({
return (
<div>
<div className={scannerStyles.controls}>
<ScannerControls>
{!running ? (
<>
<button
@@ -324,7 +328,7 @@ export function LivePage({
</button>
<button
type="button"
className={scannerStyles.outlined}
className={styles.outlined}
onClick={() => void start(false)}
>
Start capture (no sending)
@@ -337,7 +341,7 @@ export function LivePage({
</button>
<button
type="button"
className={scannerStyles.outlined}
className={styles.outlined}
disabled={!sendouUser}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => {
@@ -362,21 +366,22 @@ export function LivePage({
</option>
))}
</select>
<span
className={clsx(scannerStyles.status, {
[scannerStyles.detected]: status === "detected",
[scannerStyles.watching]: status === "watching",
[scannerStyles.idle]:
status !== "detected" && status !== "watching",
})}
<ScannerStatusPill
variant={
status === "detected"
? "detected"
: status === "watching"
? "watching"
: "idle"
}
>
{status}
{gateScore !== null ? ` · gate ${gateScore.toFixed(2)}` : null}
</span>
</ScannerStatusPill>
{liveSend ? (
<span className={clsx(scannerStyles.status, scannerStyles.watching)}>
<ScannerStatusPill variant="watching">
sending matches live
</span>
</ScannerStatusPill>
) : null}
<LiveMenu
canSaveFixture={running}
@@ -401,21 +406,14 @@ export function LivePage({
void clearEvents().then(refreshFeed);
}}
/>
</div>
{error ? <p className={scannerStyles.error}>{error}</p> : null}
{sendouError ? (
<p className={scannerStyles.error}>{sendouError}</p>
) : null}
<div className={scannerStyles.liveLayout}>
<video
ref={videoRef}
className={scannerStyles.preview}
muted
playsInline
/>
<div className={scannerStyles.feed}>
</ScannerControls>
{error ? <p className="text-error">{error}</p> : null}
{sendouError ? <p className="text-error">{sendouError}</p> : null}
<ScannerSplitLayout>
<video ref={videoRef} className={styles.preview} muted playsInline />
<ScannerFeed>
{feed.length === 0 ? (
<p className={scannerStyles.score}>No detections yet.</p>
<p className="text-xxs text-lighter">No detections yet.</p>
) : null}
<MatchLobbyTabs
matches={builtMatches}
@@ -513,8 +511,8 @@ export function LivePage({
/>
))
: null}
</div>
</div>
</ScannerFeed>
</ScannerSplitLayout>
</div>
);
}
@@ -539,13 +537,7 @@ function LiveMenu({
}) {
return (
<SendouMenu
trigger={
<SendouButton
icon={<Ellipsis />}
className={scannerStyles.iconMenu}
aria-label="More actions"
/>
}
trigger={<ScannerMenuButton icon={<Ellipsis />} label="More actions" />}
>
<SendouMenuItem
icon={<Camera />}

View File

@@ -2,7 +2,7 @@ import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import styles from "./EventCard.module.css";
import { EventCardMeta, EventCardShell } from "./EventCardShell";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { modeLabel, stageLabel } from "./labels";
@@ -22,8 +22,8 @@ export function MapStartCard(props: {
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<EventCardShell>
<EventCardMeta>
<MetaPills
t={t}
confidence={confidence}
@@ -40,7 +40,7 @@ export function MapStartCard(props: {
onInspect={onInspect}
fixture={{ data, type: "MapStart" }}
/>
</div>
</div>
</EventCardMeta>
</EventCardShell>
);
}

View File

@@ -26,6 +26,14 @@ import styles from "./MatchCard.module.css";
/** the game score a knockout wins at */
const KO_MATCH_SCORE = 100;
const SEND_STATE_CLASS: Record<SendStatus["state"], string> = {
queued: styles.queued,
sending: styles.sending,
sent: styles.sent,
unlinked: styles.unlinked,
failed: styles.failed,
};
const SEND_CHIP_LABELS: Record<Exclude<SendStatus["state"], "sent">, string> = {
queued: "queued",
sending: "sending…",
@@ -142,7 +150,7 @@ export function MatchCard({
const className = clsx(
styles.matchCard,
send?.state ? styles[send.state] : null,
send?.state ? SEND_STATE_CLASS[send.state] : null,
{
[styles.enter]: enter,
[styles.live]: live,
@@ -322,7 +330,7 @@ function StatusChip({
if (send) {
return (
<span
className={clsx(styles.chip, styles[send.state])}
className={clsx(styles.chip, SEND_STATE_CLASS[send.state])}
title={send.error}
>
{send.state === "queued" || send.state === "sending" ? (

View File

@@ -11,7 +11,12 @@ import {
type MinimapTeammate,
} from "../core/detectors/minimap/index";
import type { CardSlot } from "../core/detectors/minimap/rois";
import eventCardStyles from "./EventCard.module.css";
import {
EventCardMeta,
EventCardShell,
EventCardTeam,
EventCardTeams,
} from "./EventCardShell";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { stageLabel } from "./labels";
@@ -40,12 +45,21 @@ function AbilityRow({
);
}
/** `up` needs no rotation, `self` renders a dot instead of a chevron */
const SLOT_ROTATION_CLASS: Record<CardSlot, string | undefined> = {
up: undefined,
down: styles.down,
left: styles.left,
right: styles.right,
self: undefined,
};
function TeammateMarker({ slot }: { slot: CardSlot }) {
if (slot === "self") {
return <span className={styles.slotMarker}></span>;
}
return (
<span className={clsx(styles.slotMarker, styles[slot])}>
<span className={clsx(styles.slotMarker, SLOT_ROTATION_CLASS[slot])}>
<ChevronUp strokeWidth={3.5} aria-label={slot} role="img" />
</span>
);
@@ -109,8 +123,8 @@ export function MinimapCard(props: {
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<EventCardShell>
<EventCardMeta>
<MetaPills
t={t}
confidence={confidence}
@@ -125,9 +139,9 @@ export function MinimapCard(props: {
onInspect={onInspect}
fixture={{ data, type: "Minimap" }}
/>
</div>
<div className={eventCardStyles.teams}>
<div className={eventCardStyles.team}>
</EventCardMeta>
<EventCardTeams>
<EventCardTeam>
<h3>Team</h3>
{data.teammates.map((p) => (
<PlayerRow
@@ -136,9 +150,9 @@ export function MinimapCard(props: {
player={p}
/>
))}
</div>
</EventCardTeam>
{data.enemies.length > 0 ? (
<div className={eventCardStyles.team}>
<EventCardTeam>
<h3>Enemies</h3>
{data.enemies.map((p, i) => (
<PlayerRow
@@ -151,9 +165,9 @@ export function MinimapCard(props: {
player={p}
/>
))}
</div>
</EventCardTeam>
) : null}
</div>
</div>
</EventCardTeams>
</EventCardShell>
);
}

View File

@@ -2,7 +2,7 @@ import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import styles from "./EventCard.module.css";
import { EventCardMeta, EventCardShell } from "./EventCardShell";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
@@ -28,8 +28,8 @@ export function ObjectiveCard(props: {
const holder = data.control.findIndex(Boolean);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<EventCardShell>
<EventCardMeta>
<MetaPills
t={t}
confidence={confidence}
@@ -52,7 +52,7 @@ export function ObjectiveCard(props: {
onInspect={onInspect}
fixture={{ data, type: OBJECTIVE_EVENT_TYPE }}
/>
</div>
</div>
</EventCardMeta>
</EventCardShell>
);
}

View File

@@ -2,7 +2,7 @@ import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import styles from "./EventCard.module.css";
import { EventCardMeta, EventCardShell } from "./EventCardShell";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
@@ -25,8 +25,8 @@ export function PlayerStatusCard(props: {
.join("");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<EventCardShell>
<EventCardMeta>
<MetaPills
t={t}
confidence={confidence}
@@ -46,7 +46,7 @@ export function PlayerStatusCard(props: {
onInspect={onInspect}
fixture={{ data, type: PLAYER_STATUS_EVENT_TYPE }}
/>
</div>
</div>
</EventCardMeta>
</EventCardShell>
);
}

View File

@@ -1,3 +1,55 @@
/*
The scanner's outermost box: it names the container the pages' container
queries key off, and carries the app's own button look. Design tokens come
from sendou.ink's app/styles/vars.css.
*/
.app {
container: scanner / inline-size;
/* the app's own buttons, kept at :where() specificity so any component
class overrides it without having to win an ordering race */
& :where(button) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s-1-5);
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-family: inherit;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
padding: 0 var(--field-padding);
height: var(--field-size-sm);
white-space: nowrap;
user-select: none;
& > svg {
width: 16px;
height: 16px;
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&:active {
transform: translateY(1px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: initial;
}
}
}
.topbar {
display: flex;
align-items: center;

View File

@@ -3,7 +3,6 @@ import { Link } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useSearchParam } from "~/modules/search-params/hooks";
import { SCANNER_PAGE } from "~/utils/urls";
import scannerStyles from "../scanner.module.css";
import {
SCANNER_TABS,
type ScannerTab,
@@ -38,7 +37,7 @@ export function ScannerApp() {
);
return (
<div className={scannerStyles.app}>
<div className={styles.app}>
<header className={styles.topbar}>
<nav>
{SCANNER_TABS.map((tabOption) => (

View File

@@ -1,60 +1,3 @@
/*
Chrome shared by the scanner's pages and cards. Everything is scoped under
`.app`, whose container name the pages' container queries key off; design
tokens come from sendou.ink's app/styles/vars.css.
*/
.app {
container: scanner / inline-size;
/* the app's own buttons, kept at :where() specificity so any component
class overrides it without having to win an ordering race */
& :where(button) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s-1-5);
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-family: inherit;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
padding: 0 var(--field-padding);
height: var(--field-size-sm);
white-space: nowrap;
user-select: none;
& > svg {
width: 16px;
height: 16px;
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&:active {
transform: translateY(1px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: initial;
}
}
}
button.outlined {
background: transparent;
color: var(--color-text-accent);
}
/* secondary actions live behind this icon-only trigger, pushed to the row's end.
Sized explicitly: it is a SendouButton, which brings a competing base look */
button.iconMenu {
@@ -124,13 +67,6 @@ button.iconMenu {
background: var(--color-success-low);
}
@keyframes scanner-pulse {
50% {
opacity: 0.25;
transform: scale(0.8);
}
}
.liveLayout {
display: grid;
grid-template-columns: minmax(320px, 640px) minmax(0, 1fr);
@@ -138,28 +74,12 @@ button.iconMenu {
align-items: start;
}
.preview {
width: 100%;
background: #000;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
.feed {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.score {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
.error {
color: var(--color-error);
}
.dropzone {
border: var(--border-width) dashed var(--color-border-high);
border-radius: var(--radius-box);
@@ -201,12 +121,6 @@ button.iconMenu {
.liveLayout {
grid-template-columns: minmax(0, 1fr);
}
/* stacked, the preview would otherwise push the feed off the screen */
.preview {
max-height: 45vh;
object-fit: contain;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -214,3 +128,10 @@ button.iconMenu {
animation: none;
}
}
@keyframes scanner-pulse {
50% {
opacity: 0.25;
transform: scale(0.8);
}
}

View File

@@ -0,0 +1,94 @@
/**
* Page chrome shared by the scanner's live, VoD and screenshot pages: the
* controls row and its status pill, the preview/feed split and the file
* dropzone the VoD and screenshot pages both start from.
*/
import clsx from "clsx";
import * as React from "react";
import { SendouButton } from "~/components/elements/Button";
import styles from "./ScannerChrome.module.css";
export function ScannerControls({ children }: { children: React.ReactNode }) {
return <div className={styles.controls}>{children}</div>;
}
export function ScannerStatusPill({
variant,
children,
}: {
variant: "idle" | "watching" | "detected";
children: React.ReactNode;
}) {
return (
<span
className={clsx(styles.status, {
[styles.idle]: variant === "idle",
[styles.watching]: variant === "watching",
[styles.detected]: variant === "detected",
})}
>
{children}
</span>
);
}
export function ScannerSplitLayout({
style,
children,
}: {
style?: React.CSSProperties;
children: React.ReactNode;
}) {
return (
<div className={styles.liveLayout} style={style}>
{children}
</div>
);
}
export function ScannerFeed({ children }: { children: React.ReactNode }) {
return <div className={styles.feed}>{children}</div>;
}
export function ScannerMenuButton({
icon,
label,
}: {
icon: React.JSX.Element;
label: string;
}) {
return (
<SendouButton icon={icon} className={styles.iconMenu} aria-label={label} />
);
}
export function ScannerDropzone({
onFile,
children,
}: {
onFile: (file: File) => void;
children: React.ReactNode;
}) {
const [over, setOver] = React.useState(false);
return (
// biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path
<div
className={clsx(styles.dropzone, { [styles.over]: over })}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const file = e.dataTransfer.files[0];
if (file) onFile(file);
}}
>
{children}
</div>
);
}

View File

@@ -1,5 +1,3 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import type {
ScoreboardData,
@@ -7,9 +5,17 @@ import type {
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import scannerStyles from "../scanner.module.css";
import { AbilityPopover } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import {
EventCardMeta,
EventCardNumberCell,
EventCardPlayerTable,
EventCardShell,
EventCardTeam,
EventCardTeams,
EventCardWeaponCell,
EventCardWeaponIcon,
} from "./EventCardShell";
import { FrameThumb } from "./FrameThumb";
import type { CardData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
@@ -27,34 +33,27 @@ function PlayerRows({
abilities?: PlayerAbilityMap;
}) {
return (
<table className={styles.players}>
<tbody>
{players.map((p, i) => (
<tr key={i}>
<td>
<span className={styles.weaponCell}>
{p.weaponId !== null ? (
<WeaponImage
weaponSplId={p.weaponId}
variant="build"
size={28}
className={styles.weaponIcon}
/>
) : null}
{abilities?.has(offset + i) ? (
<AbilityPopover abilities={abilities.get(offset + i)!} />
) : null}
</span>
</td>
<td>{p.name || "?"}</td>
<td className={styles.num}>{p.paint ?? "?"}p</td>
<td className={styles.num}>
{p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
</td>
</tr>
))}
</tbody>
</table>
<EventCardPlayerTable>
{players.map((p, i) => (
<tr key={i}>
<td>
<EventCardWeaponCell>
{p.weaponId !== null ? (
<EventCardWeaponIcon weaponSplId={p.weaponId} />
) : null}
{abilities?.has(offset + i) ? (
<AbilityPopover abilities={abilities.get(offset + i)!} />
) : null}
</EventCardWeaponCell>
</td>
<td>{p.name || "?"}</td>
<EventCardNumberCell>{p.paint ?? "?"}p</EventCardNumberCell>
<EventCardNumberCell>
{p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
</EventCardNumberCell>
</tr>
))}
</EventCardPlayerTable>
);
}
@@ -85,8 +84,8 @@ export function ScoreboardCard(props: {
const isScoreboardBattleLog = eventType === SCOREBOARD_BATTLE_LOG_EVENT_TYPE;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<EventCardShell>
<EventCardMeta>
<MetaPills
t={t}
confidence={confidence}
@@ -112,7 +111,7 @@ export function ScoreboardCard(props: {
) : null}
{data.timestamp ? <span>{data.timestamp}</span> : null}
{data.replayCode ? (
<span className={scannerStyles.score}>{data.replayCode}</span>
<span className="text-xxs text-lighter">{data.replayCode}</span>
) : null}
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
@@ -121,25 +120,25 @@ export function ScoreboardCard(props: {
onInspect={onInspect}
fixture={{ data, type: eventType }}
/>
</div>
<div className={styles.teams}>
<div className={clsx(styles.team, styles.win)}>
</EventCardMeta>
<EventCardTeams>
<EventCardTeam result="win">
<h3>{teamHeading("Victory", data, 0)}</h3>
<PlayerRows
players={data.players.slice(0, 4)}
offset={0}
abilities={props.abilities}
/>
</div>
<div className={clsx(styles.team, styles.lose)}>
</EventCardTeam>
<EventCardTeam result="lose">
<h3>{teamHeading("Defeat", data, 1)}</h3>
<PlayerRows
players={data.players.slice(4, 8)}
offset={4}
abilities={props.abilities}
/>
</div>
</div>
</div>
</EventCardTeam>
</EventCardTeams>
</EventCardShell>
);
}

View File

@@ -1,11 +1,15 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { AbilityGrid } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import {
EventCardMeta,
EventCardShell,
EventCardTeam,
EventCardTeams,
EventCardWeaponIcon,
} from "./EventCardShell";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel } from "./labels";
@@ -25,8 +29,8 @@ export function ScoreboardOwnCard(props: {
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<EventCardShell>
<EventCardMeta>
<MetaPills
t={t}
confidence={confidence}
@@ -52,20 +56,15 @@ export function ScoreboardOwnCard(props: {
onInspect={onInspect}
fixture={{ data, type: "ScoreboardOwn" }}
/>
</div>
<div className={clsx(styles.teams, styles.solo)}>
<div className={styles.team}>
</EventCardMeta>
<EventCardTeams layout="solo">
<EventCardTeam>
{data.weaponId !== null ? (
<WeaponImage
weaponSplId={data.weaponId}
variant="build"
size={28}
className={styles.weaponIcon}
/>
<EventCardWeaponIcon weaponSplId={data.weaponId} />
) : null}
<AbilityGrid abilities={data.abilities} />
</div>
</div>
</div>
</EventCardTeam>
</EventCardTeams>
</EventCardShell>
);
}

View File

@@ -30,7 +30,6 @@ import * as replay from "../core/detectors/scoreboard-battle-log-replay/rois";
import type { ScoreboardOwnData } from "../core/detectors/scoreboard-own/index";
import * as own from "../core/detectors/scoreboard-own/rois";
import type { DetectedEvent } from "../core/detectors/types";
import scannerStyles from "../scanner.module.css";
import { scannerSearchParams } from "../scanner-search-params";
import { claimInspectFrame } from "../store/inspect";
import { AnalyzerClient } from "../worker/client";
@@ -44,6 +43,7 @@ import {
stageLabel,
weaponLabel,
} from "./labels";
import { ScannerDropzone } from "./ScannerChrome";
import styles from "./ScreenshotPage.module.css";
type Result = Extract<WorkerResponse, { kind: "result" }>;
@@ -406,7 +406,6 @@ export function ScreenshotPage() {
const [results, setResults] = useState<Record<string, Result>>({});
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [over, setOver] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
@@ -516,23 +515,7 @@ export function ScreenshotPage() {
return (
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const file = e.dataTransfer.files[0];
if (file) void analyze(file);
}}
>
<ScannerDropzone onFile={(file) => void analyze(file)}>
Drop a frame (PNG/JPEG) here, or{" "}
<label>
pick a file
@@ -548,8 +531,8 @@ export function ScreenshotPage() {
/>
</label>
{busy ? " — analyzing…" : null}
</div>
{error ? <p className={scannerStyles.error}>{error}</p> : null}
</ScannerDropzone>
{error ? <p className="text-error">{error}</p> : null}
<div
className={styles.frame}
@@ -922,7 +905,7 @@ export function ScreenshotPage() {
alt={c.id}
/>
{c.id}
<span className={scannerStyles.score}>
<span className="text-xxs text-lighter">
{c.score.toFixed(3)}
</span>
</span>
@@ -932,14 +915,14 @@ export function ScreenshotPage() {
<td>
<RoiCrop frame={frame} roi={roi.name} />
<b>{player?.name || "—"}</b>{" "}
<span className={scannerStyles.score}>
<span className="text-xxs text-lighter">
{dbg?.nameScore.toFixed(3)}
</span>
</td>
<td>
<RoiCrop frame={frame} roi={roi.paint} />
<b>{player?.paint ?? "—"}</b>{" "}
<span className={scannerStyles.score}>
<span className="text-xxs text-lighter">
{dbg?.paintScore.toFixed(3)}
</span>
</td>
@@ -949,7 +932,7 @@ export function ScreenshotPage() {
<span className={styles.candidate} key={s}>
<RoiCrop frame={frame} roi={roi.stats[s]} scale={2} />
<b>{[player?.ka, player?.d, player?.s][s] ?? "—"}</b>
<span className={scannerStyles.score}>
<span className="text-xxs text-lighter">
{dbg?.statScores[s].toFixed(2)}
</span>
</span>

View File

@@ -2,7 +2,7 @@ import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import styles from "./EventCard.module.css";
import { EventCardMeta, EventCardShell } from "./EventCardShell";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { mainWeaponLabel } from "./labels";
@@ -30,8 +30,8 @@ export function StripWeaponsCard(props: {
.join(" | ");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<EventCardShell>
<EventCardMeta>
<MetaPills
t={t}
confidence={confidence}
@@ -49,7 +49,7 @@ export function StripWeaponsCard(props: {
onInspect={onInspect}
fixture={{ data, type: STRIP_WEAPONS_EVENT_TYPE }}
/>
</div>
</div>
</EventCardMeta>
</EventCardShell>
);
}

View File

@@ -97,3 +97,18 @@ button.vodDelete {
flex-wrap: wrap;
}
}
.preview {
width: 100%;
background: #000;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
@container scanner (width < 700px) {
/* stacked, the preview would otherwise push the feed off the screen */
.preview {
max-height: 45vh;
object-fit: contain;
}
}

View File

@@ -39,7 +39,6 @@ import {
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import scannerStyles from "../scanner.module.css";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import {
@@ -66,6 +65,14 @@ import { formatTime, useEventDateTimeFormatter } from "./format";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { playerStatusTeams } from "./player-status-view";
import {
ScannerControls,
ScannerDropzone,
ScannerFeed,
ScannerMenuButton,
ScannerSplitLayout,
ScannerStatusPill,
} from "./ScannerChrome";
import {
countIngestableMatches,
type SendouUser,
@@ -155,7 +162,6 @@ export function VodPage({
const [vods, setVods] = useState<VodSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [telemetry, setTelemetry] = useState<ScanTelemetry | null>(null);
const [over, setOver] = useState(false);
const [eventsOpen, setEventsOpen] = useState(false);
const [resultsSend, setResultsSend] = useState<ResultsSend | null>(null);
@@ -555,23 +561,7 @@ export function VodPage({
return (
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const file = e.dataTransfer.files[0];
if (file) void scan(file);
}}
>
<ScannerDropzone onFile={(file) => void scan(file)}>
Drop a VoD (video file) here, or{" "}
<label>
pick a file
@@ -586,20 +576,21 @@ export function VodPage({
}}
/>
</label>
</div>
<div className={scannerStyles.controls}>
</ScannerDropzone>
<ScannerControls>
{showVodView ? (
<>
<button type="button" onClick={backToList}>
All VoDs
</button>
<span
className={clsx(scannerStyles.status, {
[scannerStyles.watching]: status === "scanning",
[scannerStyles.detected]: status === "done",
[scannerStyles.idle]:
status !== "scanning" && status !== "done",
})}
<ScannerStatusPill
variant={
status === "scanning"
? "watching"
: status === "done"
? "detected"
: "idle"
}
>
{source === "stored" ? "saved" : status}
{fileName ? ` · ${fileName}` : null}
@@ -607,9 +598,9 @@ export function VodPage({
{gateScore !== null && status === "scanning"
? ` · gate ${gateScore.toFixed(2)}`
: null}
</span>
</ScannerStatusPill>
{progress ? (
<span className={scannerStyles.score}>
<span className="text-xxs text-lighter">
{formatTime(progress.t)} / {formatTime(progress.duration)}
{progress.duration > 0
? ` (${Math.round((progress.t / progress.duration) * 100)}%)`
@@ -626,7 +617,7 @@ export function VodPage({
</Link>
) : null}
{upload?.problem ? (
<span className={scannerStyles.score}>
<span className="text-xxs text-lighter">
upload unavailable: {upload.problem}
</span>
) : null}
@@ -643,8 +634,8 @@ export function VodPage({
) : null}
{resultsSend ? (
<span
className={clsx(scannerStyles.score, {
[scannerStyles.error]:
className={clsx("text-xxs text-lighter", {
"text-error":
resultsSend.state === "done" && Boolean(resultsSend.error),
})}
>
@@ -663,22 +654,22 @@ export function VodPage({
) : null}
</>
) : null}
</div>
{error ? <p className={scannerStyles.error}>{error}</p> : null}
</ScannerControls>
{error ? <p className="text-error">{error}</p> : null}
{showVodView && telemetry ? (
<TelemetryPanel telemetry={telemetry} />
) : null}
{!showVodView ? (
<div className={styles.vodList}>
{vods.length === 0 ? (
<p className={scannerStyles.score}>
<p className="text-xxs text-lighter">
No saved VoDs yet scan one and it will show up here.
</p>
) : null}
{vods.map((vod) => (
<div key={vod.name} className={styles.vodItem}>
<span className={styles.vodName}>{vod.name}</span>
<span className={clsx(scannerStyles.score, styles.vodMeta)}>
<span className={clsx("text-xxs text-lighter", styles.vodMeta)}>
{vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "}
{formatTime(vod.duration)} · {formatSavedAt(vod.savedAt)}
</span>
@@ -702,8 +693,7 @@ export function VodPage({
))}
</div>
) : null}
<div
className={scannerStyles.liveLayout}
<ScannerSplitLayout
style={{
display: showVodView ? undefined : "none",
// a reopened saved VoD has no video to review — give the feed the full width
@@ -713,12 +703,12 @@ export function VodPage({
<div style={{ display: source === "scan" ? undefined : "none" }}>
<canvas
ref={previewRef}
className={scannerStyles.preview}
className={styles.preview}
style={{ display: status === "scanning" ? "block" : "none" }}
/>
<video
ref={videoRef}
className={scannerStyles.preview}
className={styles.preview}
muted
playsInline
controls
@@ -727,9 +717,9 @@ export function VodPage({
}}
/>
</div>
<div className={scannerStyles.feed}>
<ScannerFeed>
{matches.length === 0 ? (
<p className={scannerStyles.score}>
<p className="text-xxs text-lighter">
{status === "scanning"
? "Scanning — matches appear here as scoreboards are detected."
: "No matches found in this VoD."}
@@ -820,8 +810,8 @@ export function VodPage({
/>
))
: null}
</div>
</div>
</ScannerFeed>
</ScannerSplitLayout>
</div>
);
}
@@ -836,13 +826,7 @@ function ExportMenu({
}) {
return (
<SendouMenu
trigger={
<SendouButton
icon={<Download />}
className={scannerStyles.iconMenu}
aria-label="Export"
/>
}
trigger={<ScannerMenuButton icon={<Download />} label="Export" />}
>
<SendouMenuItem
icon={<FileText />}

View File

@@ -2,15 +2,14 @@ import { differenceInMinutes } from "date-fns";
import { Ban, Vote } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import {
IconBanner,
MatchBanner,
MatchBannerContainer,
MatchBannerInfoBadge,
MultiMatchBanner,
} from "~/components/match-page/MatchBanner";
import bannerStyles from "~/components/match-page/MatchBanner.module.css";
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
@@ -186,9 +185,9 @@ function CurrentMapVotesBadge({
return (
<SendouPopover
trigger={
<SendouButton variant="minimal" className={bannerStyles.infoBadge}>
<MatchBannerInfoBadge>
{voters.length} <Vote />
</SendouButton>
</MatchBannerInfoBadge>
}
>
<div className="stack sm">

View File

@@ -0,0 +1,8 @@
.formDivider {
margin-block: var(--s-6);
}
.mapPreferencesDivider {
margin-block-start: var(--s-6);
margin-block-end: var(--s-2);
}

View File

@@ -20,13 +20,13 @@ import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { action } from "../actions/t.$customUrl.edit.server";
import { loader } from "../loaders/t.$customUrl.edit.server";
import styles from "../team.module.css";
import {
editTeamActionSchema,
editTeamFormSchema,
updateTeamCustomThemeSchema,
updateTeamMapModePreferencesSchema,
} from "../team-schemas";
import styles from "./t.$customUrl.edit.module.css";
export { action, loader };

View File

@@ -0,0 +1,117 @@
.actionButtons {
display: flex;
justify-content: center;
align-items: center;
gap: var(--s-2);
flex-wrap: wrap;
}
.results {
background-color: var(--color-bg-higher);
max-width: 32rem;
margin: 0 auto;
border-radius: var(--radius-box);
padding: var(--s-1) var(--s-4);
font-weight: var(--weight-bold);
font-size: var(--font-sm);
display: flex;
justify-content: space-between;
width: min(100%, 48rem);
color: var(--color-text);
white-space: nowrap;
}
.resultsPlacements {
list-style: none;
display: flex;
gap: var(--s-4);
& > li {
display: flex;
align-items: center;
gap: var(--s-1);
}
}
.member {
display: none;
flex-direction: column;
}
.memberSection {
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-2) var(--s-4);
font-size: var(--font-xl);
font-weight: var(--weight-bold);
display: flex;
justify-content: space-between;
align-items: center;
height: 4.5rem;
}
.memberAvatarNameContainer {
display: flex;
align-items: center;
gap: var(--s-4);
color: var(--color-text);
font-weight: var(--weight-bold);
}
.memberAvatar {
background-color: var(--color-bg);
padding: var(--s-2);
border-radius: var(--radius-avatar);
}
.memberRole {
margin-left: auto;
font-size: var(--font-sm);
color: var(--color-text-high);
margin-inline-end: var(--s-3);
font-weight: var(--weight-bold);
}
.memberRoleMobile {
color: var(--color-text-high);
font-weight: var(--weight-bold);
}
.memberCardContainer {
width: 16rem;
margin: 0 auto;
display: flex;
text-align: center;
flex-direction: column;
}
.memberCard {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-2);
padding: var(--s-3);
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
font-size: var(--font-lg);
font-weight: var(--weight-bold);
}
.memberCardName {
font-weight: var(--weight-bold);
margin-block-start: var(--s-0-5);
}
@container (width >= 640px) {
.member {
display: flex;
}
.memberCardContainer {
display: none;
}
.actionButtons {
justify-content: flex-end;
}
}

View File

@@ -32,7 +32,6 @@ import invariant from "~/utils/invariant";
import { editTeamPage, manageTeamRosterPage, userPage } from "~/utils/urls";
import { action } from "../actions/t.$customUrl.index.server";
import type * as TeamRepository from "../TeamRepository.server";
import styles from "../team.module.css";
import { teamProfilePageActionSchema } from "../team-schemas";
import {
getMemberRoleType,
@@ -40,6 +39,7 @@ import {
isTeamOwner,
resolveNewOwner,
} from "../team-utils";
import styles from "./t.$customUrl.index.module.css";
export { action };

View File

@@ -0,0 +1,7 @@
.inviteContainer {
margin-block-start: var(--s-16);
display: flex;
flex-direction: column;
gap: var(--s-12);
align-items: center;
}

View File

@@ -5,7 +5,7 @@ import { SubmitButton } from "~/components/SubmitButton";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { action } from "../actions/t.$customUrl.join.server";
import { loader } from "../loaders/t.$customUrl.join.server";
import styles from "../team.module.css";
import styles from "./t.$customUrl.join.module.css";
export { action, loader };

View File

@@ -0,0 +1,155 @@
.banner {
background-image:
linear-gradient(
to bottom,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.6)
),
var(--team-banner-img);
background-size: cover;
width: 100%;
aspect-ratio: 2 / 1;
display: grid;
grid-template-areas: "flags ." "avatar name";
padding: var(--s-5);
border-radius: var(--radius-box);
}
.bannerPlaceholder {
height: 6rem;
background-color: var(--color-accent-low);
}
.bannerFlags {
grid-area: flags;
margin-top: -5px;
display: none;
column-gap: var(--s-4);
}
.bannerName {
grid-area: name;
align-self: flex-end;
justify-self: flex-end;
font-size: 36px;
line-height: 1;
font-weight: var(--weight-bold);
color: #fff;
display: none;
align-items: center;
gap: var(--s-3);
position: relative;
}
.bskyLink {
padding: var(--s-1);
border: 1px solid;
border-radius: 50%;
border-color: #1285fe;
background-color: #1285fe2f;
height: 24.4px;
width: 24.4px;
display: grid;
place-items: center;
& > svg {
width: 0.9rem;
}
& path {
fill: #1285fe;
}
}
.bannerAvatar {
grid-area: avatar;
align-self: flex-end;
margin-bottom: -110px;
& > div {
padding: var(--s-2);
background-color: var(--color-bg);
border-radius: var(--radius-avatar);
display: grid;
place-items: center;
width: 7rem;
}
& img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: var(--radius-avatar);
}
}
.bannerAvatarSpacer {
height: 4rem;
}
.mobileNameCountry {
display: flex;
font-size: var(--font-xl);
align-items: center;
font-weight: var(--weight-bold);
flex-direction: column;
}
.mobileTeamName {
display: flex;
align-items: center;
gap: var(--s-2);
}
.bannerTag {
font-size: var(--font-sm);
background-color: var(--color-accent-low);
color: var(--color-accent);
padding: var(--s-1) var(--s-1-5);
border-radius: var(--radius-box);
}
.bannerTagDesktop {
position: absolute;
bottom: 41px;
right: 0;
display: none;
}
.bannerTagMobile {
font-size: var(--font-xs);
padding: var(--s-0-5) var(--s-1);
margin-block: var(--s-1);
}
@container (width >= 640px) {
.bannerFlags {
display: flex;
}
.bannerName {
display: flex;
}
.bannerTagDesktop {
display: initial;
}
.bannerAvatar > div {
width: 10rem;
}
.bannerAvatar {
margin-left: var(--s-2);
margin-bottom: -90px;
}
.mobileNameCountry {
display: none;
}
.bannerPlaceholder {
height: 12rem;
}
}

View File

@@ -12,7 +12,7 @@ import { loader } from "../loaders/t.$customUrl.server";
export { loader };
import styles from "../team.module.css";
import styles from "./t.$customUrl.module.css";
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.loaderData) return [];

View File

@@ -1,293 +0,0 @@
.banner {
background-image:
linear-gradient(
to bottom,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.6)
),
var(--team-banner-img);
background-size: cover;
width: 100%;
aspect-ratio: 2 / 1;
display: grid;
grid-template-areas: "flags ." "avatar name";
padding: var(--s-5);
border-radius: var(--radius-box);
}
.bannerPlaceholder {
height: 6rem;
background-color: var(--color-accent-low);
}
.bannerFlags {
grid-area: flags;
margin-top: -5px;
display: none;
column-gap: var(--s-4);
}
.bannerName {
grid-area: name;
align-self: flex-end;
justify-self: flex-end;
font-size: 36px;
line-height: 1;
font-weight: var(--weight-bold);
color: #fff;
display: none;
align-items: center;
gap: var(--s-3);
position: relative;
}
.bskyLink {
padding: var(--s-1);
border: 1px solid;
border-radius: 50%;
border-color: #1285fe;
background-color: #1285fe2f;
height: 24.4px;
width: 24.4px;
display: grid;
place-items: center;
& > svg {
width: 0.9rem;
}
& path {
fill: #1285fe;
}
}
.bannerAvatar {
grid-area: avatar;
align-self: flex-end;
margin-bottom: -110px;
& > div {
padding: var(--s-2);
background-color: var(--color-bg);
border-radius: var(--radius-avatar);
display: grid;
place-items: center;
width: 7rem;
}
& img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: var(--radius-avatar);
}
}
.bannerAvatarSpacer {
height: 4rem;
}
.mobileNameCountry {
display: flex;
font-size: var(--font-xl);
align-items: center;
font-weight: var(--weight-bold);
flex-direction: column;
}
.mobileTeamName {
display: flex;
align-items: center;
gap: var(--s-2);
}
.bannerTag {
font-size: var(--font-sm);
background-color: var(--color-accent-low);
color: var(--color-accent);
padding: var(--s-1) var(--s-1-5);
border-radius: var(--radius-box);
}
.bannerTagDesktop {
position: absolute;
bottom: 41px;
right: 0;
display: none;
}
.bannerTagMobile {
font-size: var(--font-xs);
padding: var(--s-0-5) var(--s-1);
margin-block: var(--s-1);
}
.actionButtons {
display: flex;
justify-content: center;
align-items: center;
gap: var(--s-2);
flex-wrap: wrap;
}
.results {
background-color: var(--color-bg-higher);
max-width: 32rem;
margin: 0 auto;
border-radius: var(--radius-box);
padding: var(--s-1) var(--s-4);
font-weight: var(--weight-bold);
font-size: var(--font-sm);
display: flex;
justify-content: space-between;
width: min(100%, 48rem);
color: var(--color-text);
white-space: nowrap;
}
.resultsPlacements {
list-style: none;
display: flex;
gap: var(--s-4);
& > li {
display: flex;
align-items: center;
gap: var(--s-1);
}
}
.member {
display: none;
flex-direction: column;
}
.memberSection {
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-2) var(--s-4);
font-size: var(--font-xl);
font-weight: var(--weight-bold);
display: flex;
justify-content: space-between;
align-items: center;
height: 4.5rem;
}
.memberAvatarNameContainer {
display: flex;
align-items: center;
gap: var(--s-4);
color: var(--color-text);
font-weight: var(--weight-bold);
}
.memberAvatar {
background-color: var(--color-bg);
padding: var(--s-2);
border-radius: var(--radius-avatar);
}
.memberRole {
margin-left: auto;
font-size: var(--font-sm);
color: var(--color-text-high);
margin-inline-end: var(--s-3);
font-weight: var(--weight-bold);
}
.memberRoleMobile {
color: var(--color-text-high);
font-weight: var(--weight-bold);
}
.memberCardContainer {
width: 16rem;
margin: 0 auto;
display: flex;
text-align: center;
flex-direction: column;
}
.memberCard {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-2);
padding: var(--s-3);
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
font-size: var(--font-lg);
font-weight: var(--weight-bold);
}
.memberCardName {
font-weight: var(--weight-bold);
margin-block-start: var(--s-0-5);
}
.inviteContainer {
margin-block-start: var(--s-16);
display: flex;
flex-direction: column;
gap: var(--s-12);
align-items: center;
}
.imageLinksList {
display: flex;
gap: var(--s-8);
padding-left: var(--s-4);
font-size: var(--font-sm);
}
.formDivider {
margin-block: var(--s-6);
}
.mapPreferencesDivider {
margin-block-start: var(--s-6);
margin-block-end: var(--s-2);
}
@container (width >= 640px) {
.bannerFlags {
display: flex;
}
.bannerName {
display: flex;
}
.bannerTagDesktop {
display: initial;
}
.bannerAvatar > div {
width: 10rem;
}
.bannerAvatar {
margin-left: var(--s-2);
margin-bottom: -90px;
}
.mobileNameCountry {
display: none;
}
.member {
display: flex;
}
.memberCardContainer {
display: none;
}
.bannerPlaceholder {
height: 12rem;
}
.actionButtons {
justify-content: flex-end;
}
}

View File

@@ -0,0 +1,11 @@
.tableMode {
background-color: var(--color-bg);
border-radius: 100%;
padding: var(--s-1);
}
.time {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}

View File

@@ -1,14 +1,20 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Image, WeaponImage } from "~/components/Image";
import { Image } from "~/components/Image";
import {
RankTable,
RankTableInnerRow,
RankTableRank,
RankTableRow,
RankTableWeaponImage,
} from "~/components/RankTable";
import {
topSearchPage,
topSearchPlayerPage,
} from "~/features/top-search/top-search-urls";
import { brandImageUrl, modeImageUrl } from "~/utils/urls";
import styles from "../top-search.module.css";
import { monthYearToSpan } from "../top-search-utils";
import type * as XRankPlacementRepository from "../XRankPlacementRepository.server";
import styles from "./Placements.module.css";
interface PlacementsTableProps {
placements: Array<XRankPlacementRepository.FindPlacement>;
@@ -25,20 +31,19 @@ export function PlacementsTable({
const { t } = useTranslation(["game-misc"]);
return (
<div className={styles.table}>
<RankTable>
{placements.map((placement, i) => (
<Link
<RankTableRow
to={
type === "MODE_INFO"
? topSearchPage(placement)
: topSearchPlayerPage(placement.playerId)
}
key={placement.id}
className={styles.tableRow}
data-testid={`placement-row-${i}`}
testId={`placement-row-${i}`}
>
<div className={styles.tableInnerRow}>
<div className={styles.tableRank}>{placement.rank}</div>
<RankTableInnerRow>
<RankTableRank>{placement.rank}</RankTableRank>
{type === "MODE_INFO" ? (
<>
<div className={styles.tableMode}>
@@ -68,13 +73,7 @@ export function PlacementsTable({
</div>
</>
) : null}
<WeaponImage
className={styles.tableWeapon}
variant="build"
weaponSplId={placement.weaponSplId}
width={32}
height={32}
/>
<RankTableWeaponImage weaponSplId={placement.weaponSplId} />
{type === "PLAYER_NAME" ? <div>{placement.name}</div> : null}
{type === "MODE_INFO" ? (
<div className={styles.time}>
@@ -84,10 +83,10 @@ export function PlacementsTable({
{monthYearToSpan(placement).to.year}
</div>
) : null}
</div>
</RankTableInnerRow>
<div>{placement.power.toFixed(1)}</div>
</Link>
</RankTableRow>
))}
</div>
</RankTable>
);
}

View File

@@ -1,7 +1,6 @@
import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, test, vi } from "vitest";
import { render } from "vitest-browser-react";
import styles from "~/features/tournament-bracket/components/Bracket/bracket.module.css";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
import type { Bracket as BracketType } from "../../core/Bracket";
import { EliminationBracketSide } from "./Elimination";
@@ -898,7 +897,9 @@ describe("Single Elimination Bracket", () => {
<EliminationBracketSide bracket={bracket} type="single" isExpanded />,
);
const scores = screen.container.querySelectorAll(`.${styles.matchScore}`);
const scores = screen.container.querySelectorAll(
'[data-testid="match-score"]',
);
expect(scores.length).toBeGreaterThan(0);
});
@@ -955,7 +956,7 @@ describe("Single Elimination Bracket", () => {
// Should show exactly 2 round columns (Semifinals and Finals)
const roundColumns = screen.container.querySelectorAll(
`.${styles.elimRoundColumn}`,
'[data-testid="round-column"]',
);
expect(roundColumns.length).toBe(2);
});
@@ -985,13 +986,13 @@ describe("Single Elimination Bracket", () => {
// one slot per round 2 match instead of one per potential round 1 match
const firstRoundMatchWrappers = screen.container.querySelectorAll(
`[data-round-id="1"] .${styles.matchWrapper}`,
'[data-round-id="1"] :is([data-testid="match-wrapper"], [data-testid="match-bye"])',
);
expect(firstRoundMatchWrappers.length).toBe(2);
// played match connects to its destination with a straight line
const straightLines = screen.container.querySelectorAll(
`[data-round-id="1"] .${styles.matchLineStraight}`,
'[data-round-id="1"] [data-line-type="straight"]',
);
expect(straightLines.length).toBeGreaterThan(0);
@@ -1008,7 +1009,7 @@ describe("Single Elimination Bracket", () => {
);
const firstRoundMatchWrappers = screen.container.querySelectorAll(
`[data-round-id="1"] .${styles.matchWrapper}`,
'[data-round-id="1"] :is([data-testid="match-wrapper"], [data-testid="match-bye"])',
);
expect(firstRoundMatchWrappers.length).toBe(4);
});
@@ -1077,7 +1078,7 @@ describe("Double Elimination Bracket", () => {
// Small 4-team bracket only has Grand Finals (GF prefix), not regular WB rounds
const headerBox = screen.container.querySelector(
`.${styles.matchHeaderBox}`,
'[data-testid="match-header-box"]',
);
expect(headerBox?.textContent).toContain("GF");
expect(headerBox?.textContent).toContain("1.1");
@@ -1143,7 +1144,7 @@ describe("Round Robin Bracket", () => {
);
const tables = screen.container.querySelectorAll(
`.${styles.rrPlacementsTable}`,
'[data-testid="rr-standings-table"]',
);
expect(tables.length).toBe(2);
});
@@ -1182,7 +1183,9 @@ describe("Swiss Bracket", () => {
<SwissBracket bracket={bracket} bracketIdx={0} />,
);
const scores = screen.container.querySelectorAll(`.${styles.matchScore}`);
const scores = screen.container.querySelectorAll(
'[data-testid="match-score"]',
);
expect(scores.length).toBeGreaterThan(0);
});
@@ -1195,7 +1198,7 @@ describe("Swiss Bracket", () => {
);
const table = screen.container.querySelector(
`.${styles.rrPlacementsTable}`,
'[data-testid="rr-standings-table"]',
);
expect(table).not.toBeNull();
});

View File

@@ -0,0 +1,28 @@
.elimContainer {
--line-width: 30px;
display: grid;
grid-template-columns: repeat(
var(--round-count),
calc(var(--match-width) + var(--line-width))
);
overflow: visible;
}
.elimRoundMatchesContainer {
display: flex;
flex-direction: column;
justify-content: space-around;
gap: var(--s-8);
margin-top: var(--s-6);
flex: 1;
overflow: visible;
}
.elimRoundMatchesContainerTopBye {
margin-top: -18px;
}
.elimRoundColumn {
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,57 @@
import clsx from "clsx";
import type * as React from "react";
import styles from "./BracketColumns.module.css";
export function BracketColumns({
roundCount,
children,
}: {
roundCount: number;
children: React.ReactNode;
}) {
return (
<div
className={styles.elimContainer}
style={{ "--round-count": roundCount } as React.CSSProperties}
>
{children}
</div>
);
}
export function BracketColumn({
roundId,
children,
}: {
roundId?: number;
children: React.ReactNode;
}) {
return (
<div
className={styles.elimRoundColumn}
data-round-id={roundId}
data-testid="round-column"
>
{children}
</div>
);
}
export function BracketColumnMatches({
topBye,
children,
}: {
/** pulls the column up so a first round starting with a bye stays aligned */
topBye?: boolean;
children: React.ReactNode;
}) {
return (
<div
className={clsx(styles.elimRoundMatchesContainer, {
[styles.elimRoundMatchesContainerTopBye]: topBye,
})}
>
{children}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More