mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-21 18:44:51 -05:00
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
16
app/components/GearSelect.module.css
Normal file
16
app/components/GearSelect.module.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
62
app/components/RankTable.tsx
Normal file
62
app/components/RankTable.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
117
app/components/elements/SelectShell.module.css
Normal file
117
app/components/elements/SelectShell.module.css
Normal 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);
|
||||
}
|
||||
126
app/components/elements/SelectShell.tsx
Normal file
126
app/components/elements/SelectShell.tsx
Normal 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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
48
app/components/layout/SearchResults.module.css
Normal file
48
app/components/layout/SearchResults.module.css
Normal 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);
|
||||
}
|
||||
57
app/components/layout/SearchResults.tsx
Normal file
57
app/components/layout/SearchResults.tsx
Normal 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>;
|
||||
}
|
||||
29
app/components/layout/WeaponSearch.module.css
Normal file
29
app/components/layout/WeaponSearch.module.css
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
35
app/features/badges/routes/badges.$id.edit.module.css
Normal file
35
app/features/badges/routes/badges.$id.edit.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
38
app/features/badges/routes/badges.$id.module.css
Normal file
38
app/features/badges/routes/badges.$id.module.css
Normal 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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
33
app/features/badges/routes/badges.module.css
Normal file
33
app/features/badges/routes/badges.module.css
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
59
app/features/plus-voting/routes/plus.voting.module.css
Normal file
59
app/features/plus-voting/routes/plus.voting.module.css
Normal 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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
101
app/features/scanner/components/EventCardShell.module.css
Normal file
101
app/features/scanner/components/EventCardShell.module.css
Normal 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);
|
||||
}
|
||||
101
app/features/scanner/components/EventCardShell.tsx
Normal file
101
app/features/scanner/components/EventCardShell.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
94
app/features/scanner/components/ScannerChrome.tsx
Normal file
94
app/features/scanner/components/ScannerChrome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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">
|
||||
|
||||
8
app/features/team/routes/t.$customUrl.edit.module.css
Normal file
8
app/features/team/routes/t.$customUrl.edit.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.formDivider {
|
||||
margin-block: var(--s-6);
|
||||
}
|
||||
|
||||
.mapPreferencesDivider {
|
||||
margin-block-start: var(--s-6);
|
||||
margin-block-end: var(--s-2);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
117
app/features/team/routes/t.$customUrl.index.module.css
Normal file
117
app/features/team/routes/t.$customUrl.index.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
7
app/features/team/routes/t.$customUrl.join.module.css
Normal file
7
app/features/team/routes/t.$customUrl.join.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.inviteContainer {
|
||||
margin-block-start: var(--s-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-12);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
155
app/features/team/routes/t.$customUrl.module.css
Normal file
155
app/features/team/routes/t.$customUrl.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
11
app/features/top-search/components/Placements.module.css
Normal file
11
app/features/top-search/components/Placements.module.css
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user