Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,18 @@
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import { config } from "~/modules/i18n/config";
import { resources } from "~/modules/i18n/resources.browser";
import "~/styles/vars.css";
import "~/styles/normalize.css";
import "~/styles/common.css";
import "~/styles/utils.css";
import "~/styles/flags.css";
document.documentElement.classList.add("dark");
i18next.use(initReactI18next).init({
...config,
lng: "en",
resources,
});

View File

@@ -0,0 +1,34 @@
.container {
display: flex;
width: 100%;
flex-direction: column;
justify-content: center;
gap: var(--s-3);
}
.slots {
display: grid;
margin: 0 auto;
gap: var(--s-2);
grid-template-columns: repeat(4, max-content);
place-items: center;
}
.abilityButtons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-1);
touch-action: manipulation;
}
.abilityButton {
padding: var(--s-0-5);
border: var(--border-style);
border-radius: 50%;
background-color: var(--color-bg-ability);
&.isDragging {
box-shadow: 0 0 100px inset rgb(255 255 255 / 25%);
}
}

View File

@@ -0,0 +1,210 @@
import clsx from "clsx";
import * as React from "react";
import { abilities } from "~/modules/in-game-lists/abilities";
import type { BuildAbilitiesTupleWithUnknown } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { abilityImageUrl } from "~/utils/urls";
import styles from "./AbilitiesSelector.module.css";
import { Ability } from "./Ability";
import { Image } from "./Image";
interface AbilitiesSelectorProps {
selectedAbilities: BuildAbilitiesTupleWithUnknown;
onChange: (newAbilities: BuildAbilitiesTupleWithUnknown) => void;
}
export function AbilitiesSelector({
selectedAbilities,
onChange,
}: AbilitiesSelectorProps) {
const [, startTransition] = React.useTransition();
const onSlotClick = ({
rowI,
abilityI,
}: {
rowI: number;
abilityI: number;
}) => {
const abilitiesClone = JSON.parse(
JSON.stringify(selectedAbilities),
) as BuildAbilitiesTupleWithUnknown;
const row = abilitiesClone[rowI];
invariant(row);
invariant(row.length === 4);
// no need to trigger a rerender
if (row[abilityI] === "UNKNOWN") return;
row[abilityI] = "UNKNOWN";
onChange(abilitiesClone);
};
const onButtonClick = (ability: (typeof abilities)[number]) => {
startTransition(() => {
onChange(addAbility({ oldAbilities: selectedAbilities, ability }));
});
};
const [draggingAbility, setDraggingAbility] = React.useState<
(typeof abilities)[number] | undefined
>();
const onDragStart =
(ability: (typeof abilities)[number]) => (event: React.DragEvent) => {
setDraggingAbility(ability);
event.dataTransfer.setData("text/plain", JSON.stringify(ability));
};
const onDragEnd = () => {
setDraggingAbility(undefined);
};
const onDrop =
(atRowIndex: number, atAbilityIndex: number) =>
(event: React.DragEvent) => {
event.preventDefault();
const ability = JSON.parse(
event.dataTransfer.getData("text/plain"),
) as (typeof abilities)[number];
onChange(
addAbility({
oldAbilities: selectedAbilities,
ability,
atRowIndex,
atAbilityIndex,
}),
);
};
return (
<div className={styles.container} data-testid="ability-selector">
<div className={styles.slots}>
{selectedAbilities.map((row, rowI) =>
row.map((ability, abilityI) => (
<Ability
key={abilityI}
ability={ability}
size={abilityI === 0 ? "MAIN" : "SUB"}
onClick={() => onSlotClick({ rowI, abilityI })}
dragStarted={!!draggingAbility}
dropAllowed={canPlaceAbilityAtSlot(
rowI,
abilityI,
draggingAbility,
)}
onDrop={onDrop(rowI, abilityI)}
/>
)),
)}
</div>
<div className={styles.abilityButtons}>
{abilities.map((ability) => (
<button
key={ability.name}
className={clsx(styles.abilityButton, {
[styles.isDragging]: ability.name === draggingAbility?.name,
})}
type="button"
onClick={() => onButtonClick(ability)}
data-testid={`${ability.name}-ability-button`}
draggable="true"
onDragStart={onDragStart(ability)}
onDragEnd={onDragEnd}
>
<Image
alt=""
path={abilityImageUrl(ability.name)}
width={32}
height={32}
/>
</button>
))}
</div>
</div>
);
}
const canPlaceAbilityAtSlot = (
rowIndex: number,
abilityIndex: number,
ability?: (typeof abilities)[number],
) => {
if (!ability) {
return false;
}
const legalGearTypeForMain =
rowIndex === 0
? "HEAD_MAIN_ONLY"
: rowIndex === 1
? "CLOTHES_MAIN_ONLY"
: "SHOES_MAIN_ONLY";
const isMainSlot = abilityIndex === 0;
if (
!["STACKABLE", legalGearTypeForMain].includes(ability.type) &&
isMainSlot
) {
// Can't put this type of gear in main slot
return false;
}
if (!isMainSlot && ability.type !== "STACKABLE") {
// Can't put main slot only gear to sub slots
return false;
}
return true;
};
function addAbility({
oldAbilities,
ability,
atRowIndex,
atAbilityIndex,
}: {
oldAbilities: BuildAbilitiesTupleWithUnknown;
ability: (typeof abilities)[number];
atRowIndex?: number;
atAbilityIndex?: number;
}): BuildAbilitiesTupleWithUnknown {
const abilitiesClone = JSON.parse(
JSON.stringify(oldAbilities),
) as BuildAbilitiesTupleWithUnknown;
if (atRowIndex !== undefined && atAbilityIndex !== undefined) {
// Attempt to place the ability at a specific slot since we
// were given an atRowIndex and atAbilityIndex
if (canPlaceAbilityAtSlot(atRowIndex, atAbilityIndex, ability)) {
// Assign this ability to the slot
abilitiesClone[atRowIndex][atAbilityIndex] = ability.name;
}
} else {
// Loop through all slots and attempt to place this ability
// in the first empty one
for (const [rowIndex, row] of abilitiesClone.entries()) {
for (const [abilityIndex, oldAbility] of row.entries()) {
if (oldAbility !== "UNKNOWN") {
// Skip any filled slots in this loop until we arrive at an empty one.
continue;
}
if (!canPlaceAbilityAtSlot(rowIndex, abilityIndex, ability)) {
// This ability isn't valid for this slot
continue;
}
// Assign this ability to the slot
abilitiesClone[rowIndex][abilityIndex] = ability.name;
return abilitiesClone;
}
}
}
// no-op if no available slots
return abilitiesClone;
}

View File

@@ -0,0 +1,31 @@
.ability {
width: var(--ability-size);
height: var(--ability-size);
padding: 0;
border: var(--border-style-high);
border-radius: 50%;
border-right: 0;
border-bottom: 0;
background: var(--color-bg-ability);
background-size: 100%;
transform: scale(1);
transition: all 0.1s ease;
user-select: none;
}
.isDragTarget {
background: var(--color-bg-ability);
transform: scale(1.15);
}
.dragStarted:not(.dropAllowed) {
filter: grayscale(1);
opacity: 0.3;
pointer-events: none;
}
.readonly,
.readonly:active {
cursor: default;
transform: none;
}

View File

@@ -0,0 +1,91 @@
import clsx from "clsx";
import React from "react";
import { useTranslation } from "react-i18next";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import styles from "./Ability.module.css";
import { Image } from "./Image";
const sizeMap = {
HUGE: 64,
MAIN: 42,
SUB: 32,
SUBTINY: 26,
TINY: 22,
} as const;
export function Ability({
ability,
size,
dragStarted = false,
dropAllowed = false,
onClick,
onDrop,
className,
}: {
ability: AbilityWithUnknown;
size: keyof typeof sizeMap;
dragStarted?: boolean;
dropAllowed?: boolean;
onClick?: () => void;
onDrop?: (event: React.DragEvent) => void;
className?: string;
}) {
const { t } = useTranslation(["game-misc", "builds"]);
const sizeNumber = sizeMap[size];
const [isDragTarget, setIsDragTarget] = React.useState(false);
const onDragOver = (event: React.DragEvent) => {
event.preventDefault();
setIsDragTarget(true);
};
const onDragLeave = () => {
setIsDragTarget(false);
};
const readonly = typeof onClick === "undefined" || ability === "UNKNOWN"; // Force "UNKNOWN" ability icons to be readonly
// Render an ability as a button only if it is meant to be draggable (i.e., not readonly)
const AbilityTag = readonly ? "div" : "button";
const altText =
ability !== "UNKNOWN"
? t(`game-misc:ABILITY_${ability}`)
: t("builds:emptyAbilitySlot");
return (
<AbilityTag
className={clsx(
styles.ability,
{
[styles.isDragTarget]: isDragTarget,
[styles.dragStarted]: dragStarted,
[styles.dropAllowed]: dropAllowed,
[styles.readonly]: readonly,
},
className,
)}
style={{
"--ability-size": `${sizeNumber}px`,
}}
onClick={onClick}
data-testid={`${ability}-ability`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={(event) => {
setIsDragTarget(false);
onDrop?.(event);
}}
type={readonly ? undefined : "button"}
>
<Image
alt={altText}
title={altText}
path={abilityImageUrl(ability)}
size={sizeNumber}
/>
</AbilityTag>
);
}

View File

@@ -0,0 +1,113 @@
import type * as React from "react";
import { type FetcherWithComponents, useFetcher } from "react-router";
import type { z } from "zod";
import {
type ActionsOf,
type FieldsOf,
serializeFieldValue,
} from "~/utils/action-schemas";
import { SendouButton, type SendouButtonProps } from "./elements/Button";
import { FormWithConfirm } from "./FormWithConfirm";
import { SubmitButton } from "./SubmitButton";
interface ActionButtonBaseProps<
TSchema extends z.ZodTypeAny,
TAction extends ActionsOf<TSchema>,
> extends Omit<SendouButtonProps, "type" | "name" | "value" | "form"> {
/** Action schema of the route the button submits to. Only used for typing `action` and `fields`. */
schema: TSchema;
/** `_action` to submit, narrowed to the literals of the schema. */
action: TAction;
/** Route to submit to. Defaults to the current route. */
formAction?: string;
formClassName?: string;
/** Fetcher to submit with, e.g. to share submitting state between buttons. Defaults to own fetcher. */
fetcher?: FetcherWithComponents<unknown>;
/** When set, submits only after the user confirms via a dialog. */
confirm?: {
dialogHeading: string;
description?: React.ReactNode;
submitButtonText?: string;
submitButtonVariant?: SendouButtonProps["variant"];
};
}
type ActionButtonProps<
TSchema extends z.ZodTypeAny,
TAction extends ActionsOf<TSchema>,
> = ActionButtonBaseProps<TSchema, TAction> &
// biome-ignore lint/complexity/noBannedTypes: {} models "branch with no extra fields"
({} extends FieldsOf<TSchema, TAction>
? { fields?: FieldsOf<TSchema, TAction> }
: { fields: FieldsOf<TSchema, TAction> });
/**
* Button that submits a mutation to a route action as `_action` + hidden fields,
* type checked against the route's zod action schema.
*
* @example
* <ActionButton
* schema={lookingSchema}
* action="LIKE"
* fields={{ targetGroupId: group.id }}
* >
* {t("q:looking.groups.actions.invite")}
* </ActionButton>
*/
export function ActionButton<
TSchema extends z.ZodTypeAny,
const TAction extends ActionsOf<TSchema>,
>({
schema,
action,
fields,
formAction,
formClassName,
fetcher: fetcherFromProps,
confirm,
children,
...buttonProps
}: ActionButtonProps<TSchema, TAction>) {
const ownFetcher = useFetcher();
const fetcher = fetcherFromProps ?? ownFetcher;
const fieldEntries = Object.entries(
(fields ?? {}) as Record<string, unknown>,
).flatMap(([name, value]) =>
value === undefined || value === null
? []
: ([[name, serializeFieldValue(value)]] as const),
);
if (confirm) {
return (
<FormWithConfirm
dialogHeading={confirm.dialogHeading}
description={confirm.description}
submitButtonText={confirm.submitButtonText}
submitButtonVariant={confirm.submitButtonVariant}
action={formAction}
fetcher={fetcherFromProps}
fields={[["_action", action], ...fieldEntries]}
>
<SendouButton {...buttonProps}>{children}</SendouButton>
</FormWithConfirm>
);
}
return (
<fetcher.Form method="post" action={formAction} className={formClassName}>
{fieldEntries.map(([name, value]) => (
<input type="hidden" key={name} name={name} value={value} />
))}
<SubmitButton
schema={schema}
_action={action}
state={fetcher.state}
{...buttonProps}
>
{children}
</SubmitButton>
</fetcher.Form>
);
}

View File

@@ -0,0 +1,43 @@
.alert {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
border-radius: var(--radius-box);
background-color: var(--color-info-low);
color: var(--color-info-high);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
gap: var(--s-2);
margin-inline: auto;
padding-block: var(--s-1-5);
padding-inline: var(--s-3) var(--s-4);
text-align: center;
& > svg {
height: 1.75rem;
}
}
.tiny {
font-size: var(--font-xs);
& > svg {
height: 1.25rem;
}
}
.warning {
background-color: var(--color-warning-low);
color: var(--color-warning-high);
}
.error {
background-color: var(--color-error-low);
color: var(--color-error-high);
}
.success {
background-color: var(--color-success-low);
color: var(--color-success-high);
}

View File

@@ -0,0 +1,50 @@
import clsx from "clsx";
import { Check, CircleAlert, OctagonAlert, TriangleAlert } from "lucide-react";
import type * as React from "react";
import { assertUnreachable } from "~/utils/types";
import styles from "./Alert.module.css";
export type AlertVariation = "INFO" | "WARNING" | "ERROR" | "SUCCESS";
export function Alert({
children,
textClassName,
alertClassName,
variation = "INFO",
tiny = false,
}: {
children: React.ReactNode;
textClassName?: string;
alertClassName?: string;
variation?: AlertVariation;
tiny?: boolean;
}) {
return (
<div
className={clsx(styles.alert, alertClassName, {
[styles.tiny]: tiny,
[styles.warning]: variation === "WARNING",
[styles.error]: variation === "ERROR",
[styles.success]: variation === "SUCCESS",
})}
>
<Icon variation={variation} />{" "}
<div className={textClassName}>{children}</div>
</div>
);
}
function Icon({ variation }: { variation: AlertVariation }) {
switch (variation) {
case "INFO":
return <CircleAlert />;
case "WARNING":
return <TriangleAlert />;
case "ERROR":
return <OctagonAlert />;
case "SUCCESS":
return <Check />;
default:
assertUnreachable(variation);
}
}

View File

@@ -0,0 +1,12 @@
.avatarWrapper {
flex-shrink: 0;
width: fit-content;
height: fit-content;
background-color: var(--color-bg-higher);
border-radius: var(--radius-avatar);
overflow: hidden;
& img {
display: block;
}
}

View File

@@ -0,0 +1,168 @@
import clsx from "clsx";
import * as React from "react";
import type { Tables } from "~/db/tables";
import { useHydrated } from "~/hooks/useHydrated";
import { LRUCache } from "~/modules/cache";
import { BLANK_IMAGE_URL, resolveAvatarUrl } from "~/utils/urls";
import styles from "./Avatar.module.css";
const dimensions = {
xxxs: 16,
xxxsm: 20,
xxs: 24,
xxsm: 32,
xs: 36,
sm: 44,
xsm: 62,
md: 81,
xmd: 94,
lg: 125,
} as const;
const identiconCache = new LRUCache<string, string>({ max: 500 });
function hashString(str: string) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
return hash;
}
function generateColors(hash: number) {
const hue = hash % 360;
const saturation = 65 + ((hash >>> 8) % 20);
const lightness = 50 + ((hash >>> 16) % 20);
return {
background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`,
foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
};
}
export function generateIdenticon(input: string, size = 128, gridSize = 5) {
const cacheKey = `${input}-${size}-${gridSize}`;
const cached = identiconCache.get(cacheKey);
if (cached) return cached;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
const dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false;
const insetRatio = 1 / Math.sqrt(2);
const cellSize = Math.floor((size * insetRatio) / gridSize);
const actualSize = cellSize * gridSize;
const offset = Math.floor((size - actualSize) / 2);
const halfGrid = Math.ceil(gridSize / 2);
const patternHash = hashString(input);
const colorHash = hashString(input.split("").reverse().join(""));
const colors = generateColors(colorHash);
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = colors.foreground;
const path = new Path2D();
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < halfGrid; col++) {
const bitIndex = row * halfGrid + col;
const shouldFill = (patternHash >>> bitIndex) & 1;
if (shouldFill) {
const x = offset + col * cellSize;
const y = offset + row * cellSize;
path.rect(x, y, cellSize, cellSize);
const mirrorCol = gridSize - 1 - col;
if (col !== mirrorCol) {
const mirrorX = offset + mirrorCol * cellSize;
path.rect(mirrorX, y, cellSize, cellSize);
}
}
}
}
ctx.fill(path);
const dataUrl = canvas.toDataURL();
identiconCache.set(cacheKey, dataUrl);
return dataUrl;
}
export function Avatar({
user,
url,
identiconInput,
size = "sm",
className,
alt = "",
loading = "lazy",
...rest
}: {
user?: Pick<Tables["User"], "discordId" | "discordAvatar"> & {
customAvatarUrl?: string | null;
};
url?: string | null;
identiconInput?: string;
className?: string;
alt?: string;
size: keyof typeof dimensions;
loading?: "lazy" | "eager";
} & React.ButtonHTMLAttributes<HTMLImageElement>) {
const [isErrored, setIsErrored] = React.useState(false);
const isClient = useHydrated();
// an <img> can finish loading (and fail) before React hydrates and attaches onError, so that
// error is missed — re-check on mount and fall back manually so SSR'd avatars still heal
const checkAlreadyErrored = (img: HTMLImageElement | null) => {
if (img?.complete && img.naturalWidth === 0) setIsErrored(true);
};
const identiconSource = identiconInput ?? user?.discordId ?? "unknown";
const userAvatarUrl = user
? resolveAvatarUrl({
customAvatarUrl: user.customAvatarUrl,
discordId: user.discordId,
discordAvatar: user.discordAvatar,
size: size === "lg" || size === "xmd" ? "lg" : "sm",
})
: undefined;
const avatarUrl = url ?? userAvatarUrl;
const src =
avatarUrl && !isErrored
? avatarUrl
: isClient
? generateIdenticon(identiconSource, dimensions[size], 7)
: BLANK_IMAGE_URL;
return (
<div className={clsx(styles.avatarWrapper, className)}>
<img
ref={checkAlreadyErrored}
src={src}
alt={alt}
title={alt ? alt : undefined}
width={dimensions[size]}
height={dimensions[size]}
loading={loading}
onError={() => setIsErrored(true)}
{...rest}
/>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { badgeUrl } from "~/utils/urls";
import { Image } from "./Image";
export interface BadgeProps {
badge: { displayName: string; hue?: number | null; code: string };
onClick?: () => void;
isAnimated: boolean;
size: number;
}
export function Badge({ badge, onClick, isAnimated, size }: BadgeProps) {
const commonProps = {
title: badge.displayName,
onClick,
width: size,
height: size,
style: badge.hue ? { filter: `hue-rotate(${badge.hue}deg)` } : undefined,
};
if (isAnimated) {
return (
<img
src={badgeUrl({ code: badge.code, extension: "gif" })}
alt={badge.displayName}
{...commonProps}
/>
);
}
return (
<Image
path={badgeUrl({ code: badge.code })}
alt={badge.displayName}
loading="lazy"
{...commonProps}
/>
);
}

View File

@@ -0,0 +1,120 @@
.card {
position: relative;
display: flex;
flex-direction: column;
padding: var(--s-3);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
gap: var(--s-3);
}
.private {
background-color: var(--color-bg-higher);
}
.privateText {
display: flex;
justify-content: center;
font-weight: var(--weight-semi);
gap: var(--s-1);
}
.title {
overflow: hidden;
height: 2.5rem;
font-size: var(--font-sm);
line-height: 1.25;
word-wrap: break-word;
}
.topRow {
display: flex;
justify-content: space-between;
}
.dateAuthorRow {
display: flex;
font-size: var(--font-2xs);
gap: var(--s-1);
}
.ownerLink {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modes {
position: absolute;
right: 0;
display: flex;
min-width: max-content;
margin-top: -21px;
gap: var(--s-1);
}
.weapon {
position: relative;
padding: var(--s-0-5);
border-radius: 50%;
background-color: var(--color-bg);
&:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.top500 {
position: absolute;
top: -14px;
right: 8px;
}
.weaponText {
padding-left: var(--s-1);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
}
.weapons {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s-1);
}
.gearAbilities {
display: grid;
gap: var(--s-2) var(--s-1);
grid-template-columns: repeat(5, max-content);
place-items: center;
}
.noGear {
grid-template-columns: repeat(4, max-content);
margin: 0 auto;
margin-top: 10px;
margin-bottom: 1rem;
row-gap: 28px;
}
.gear {
border-radius: 50%;
background-color: var(--color-bg);
overflow: visible;
}
.bottomRow {
display: flex;
height: 100%;
align-items: flex-end;
justify-content: center;
gap: var(--s-2);
}
.smallText {
font-size: var(--font-2xs) !important;
}

View File

@@ -0,0 +1,381 @@
import clsx from "clsx";
import {
HardDriveDownload,
Lock,
MessageCircleMore,
SquarePen,
Trash,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { analyzerPage } from "~/features/build-analyzer/analyzer-urls";
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
import {
BuildGraphic,
type BuildGraphicOwner,
} from "~/features/img-export/components/BuildGraphic";
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
import type {
Ability as AbilityType,
BuildAbilitiesTuple,
GearType,
ModeShort,
} from "~/modules/in-game-lists/types";
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
import type { UserWithPlusTier } from "~/utils/kysely.server";
import { gearTypeToInitial } from "~/utils/strings";
import {
gearImageUrl,
mainWeaponImageUrl,
modeImageUrl,
mySlugify,
navIconUrl,
userBuildsPage,
weaponBuildPage,
} from "~/utils/urls";
import { Ability } from "./Ability";
import styles from "./BuildCard.module.css";
import { LinkButton, SendouButton } from "./elements/Button";
import { SendouPopover } from "./elements/Popover";
import { SendouSwitch } from "./elements/Switch";
import { FormWithConfirm } from "./FormWithConfirm";
import { Image } from "./Image";
import { LocaleTime } from "./LocaleTime";
interface BuildProps {
build: Pick<
Tables["Build"],
| "id"
| "title"
| "description"
| "clothesGearSplId"
| "headGearSplId"
| "shoesGearSplId"
| "updatedAt"
| "isPrivate"
> & {
abilities: BuildAbilitiesTuple;
modes: ModeShort[] | null;
weapons: Array<BuildWeaponWithTop500Info>;
};
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier"> &
Partial<
Pick<BuildGraphicOwner, "customUrl" | "discordAvatar" | "customAvatarUrl">
>;
/** Set to false when the page context already shows the owner (e.g. their own builds page) */
showOwner?: boolean;
canEdit?: boolean;
}
export function BuildCard({
build,
owner,
showOwner = true,
canEdit = false,
}: BuildProps) {
const user = useUser();
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
const {
id,
title,
description,
clothesGearSplId,
headGearSplId,
shoesGearSplId,
updatedAt,
modes,
weapons,
abilities,
} = build;
const isNoGear = [headGearSplId, clothesGearSplId, shoesGearSplId].some(
(id) => typeof id !== "number",
);
return (
<div
className={clsx(styles.card, { [styles.private]: build.isPrivate })}
data-testid="build-card"
>
<div>
<div className={styles.topRow}>
{modes && modes.length > 0 && (
<div className={styles.modes}>
{modes.map((mode) => (
<Image
key={mode}
alt={t(`game-misc:MODE_LONG_${mode}` as any)}
title={t(`game-misc:MODE_LONG_${mode}` as any)}
path={modeImageUrl(mode)}
width={18}
height={18}
testId={`build-mode-${mode}`}
/>
))}
</div>
)}
<h2 className={styles.title} data-testid="build-title">
{title}
</h2>
</div>
<div className={styles.dateAuthorRow}>
{owner && showOwner ? (
<>
<Link to={userBuildsPage(owner)} className={styles.ownerLink}>
{owner.username}
</Link>
<div>•</div>
</>
) : null}
{owner?.plusTier && showOwner ? (
<>
<span>+{owner.plusTier}</span>
<div>•</div>
</>
) : null}
<div className="stack horizontal sm items-center">
{build.isPrivate ? (
<div className={styles.privateText}>
<Lock size={16} /> {t("common:build.private")}
</div>
) : null}
<LocaleTime
date={updatedAt}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
}}
className="whitespace-nowrap"
/>
</div>
</div>
</div>
<div className={styles.weapons}>
{weapons.map((weapon) => (
<RoundWeaponImage key={weapon.weaponSplId} weapon={weapon} />
))}
{weapons.length === 1 && (
<div className={styles.weaponText}>
{t(`weapons:MAIN_${weapons[0].weaponSplId}` as any)}
</div>
)}
</div>
<div
className={clsx(styles.gearAbilities, {
[styles.noGear]: isNoGear,
})}
>
<AbilitiesRowWithGear
gearType="HEAD"
abilities={abilities[0]}
gearId={headGearSplId}
/>
<AbilitiesRowWithGear
gearType="CLOTHES"
abilities={abilities[1]}
gearId={clothesGearSplId}
/>
<AbilitiesRowWithGear
gearType="SHOES"
abilities={abilities[2]}
gearId={shoesGearSplId}
/>
</div>
<div className={styles.bottomRow}>
<LinkButton
to={analyzerPage({
weaponId: weapons[0].weaponSplId,
abilities: abilities.flat(),
})}
shape="circle"
variant="minimal"
size="small"
>
<Image
size={24}
alt={t("common:pages.analyzer")}
className={styles.icon}
path={navIconUrl("analyzer")}
/>
</LinkButton>
{owner ? <BuildImageExportDialog build={build} owner={owner} /> : null}
{description ? (
<SendouPopover
trigger={
<SendouButton
shape="circle"
size="small"
variant="minimal"
icon={<MessageCircleMore />}
className={styles.smallText}
/>
}
>
{description}
</SendouPopover>
) : null}
{canEdit && (
<>
<LinkButton
shape="circle"
className={styles.smallText}
variant="minimal"
size="small"
to={`new?buildId=${id}&userId=${user!.id}`}
testId="edit-build"
icon={<SquarePen />}
/>
<FormWithConfirm
dialogHeading={t("builds:deleteConfirm", { title })}
fields={[
["buildToDeleteId", id],
["_action", "DELETE_BUILD"],
]}
>
<SendouButton
shape="circle"
size="small"
icon={<Trash />}
className={styles.smallText}
variant="minimal-destructive"
type="submit"
/>
</FormWithConfirm>
</>
)}
</div>
</div>
);
}
function BuildImageExportDialog({
build,
owner,
}: {
build: BuildProps["build"];
owner: BuildGraphicOwner;
}) {
const { t } = useTranslation(["common"]);
const [showTitle, setShowTitle] = React.useState(true);
const [showAbilityPoints, setShowAbilityPoints] = React.useState(true);
const [showAbilityChunks, setShowAbilityChunks] = React.useState(false);
return (
<ImageExportDialog
trigger={
<SendouButton
shape="circle"
size="small"
variant="minimal"
icon={<HardDriveDownload />}
className={styles.smallText}
aria-label={t("common:imageExport.export")}
/>
}
heading={t("common:imageExport.export")}
filename={`build-${mySlugify(build.title)}`}
qrCodePath={analyzerPage({
weaponId: build.weapons[0].weaponSplId,
abilities: build.abilities.flat(),
})}
settings={
<>
<SendouSwitch isSelected={showTitle} onChange={setShowTitle}>
{t("common:imageExport.buildTitle")}
</SendouSwitch>
<SendouSwitch
isSelected={showAbilityPoints}
onChange={setShowAbilityPoints}
>
{t("common:imageExport.abilityPoints")}
</SendouSwitch>
<SendouSwitch
isSelected={showAbilityChunks}
onChange={setShowAbilityChunks}
>
{t("common:imageExport.abilityChunks")}
</SendouSwitch>
</>
}
>
<BuildGraphic
build={build}
owner={owner}
showTitle={showTitle}
showAbilityPoints={showAbilityPoints}
showAbilityChunks={showAbilityChunks}
/>
</ImageExportDialog>
);
}
function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) {
const normalizedWeaponSplId = canonicalWeaponSplId(weapon.weaponSplId);
const { t } = useTranslation(["weapons"]);
const slug = mySlugify(
t(`weapons:MAIN_${normalizedWeaponSplId}`, { lng: "en" }),
);
return (
<div key={weapon.weaponSplId} className={styles.weapon}>
{weapon.isTop500 ? (
<Image
className={styles.top500}
path={navIconUrl("xsearch")}
alt=""
height={24}
width={24}
testId="top500-crown"
/>
) : null}
<Link to={weaponBuildPage(slug)}>
<Image
path={mainWeaponImageUrl(weapon.weaponSplId)}
alt={t(`weapons:MAIN_${weapon.weaponSplId}`)}
title={t(`weapons:MAIN_${weapon.weaponSplId}`)}
height={36}
width={36}
/>
</Link>
</div>
);
}
function AbilitiesRowWithGear({
gearType,
abilities,
gearId,
}: {
gearType: GearType;
abilities: AbilityType[];
gearId: number | null;
}) {
const { t } = useTranslation(["gear"]);
const translatedGearName = t(
`gear:${gearTypeToInitial(gearType)}_${gearId}` as any,
);
return (
<>
{typeof gearId === "number" ? (
<Image
height={64}
width={64}
alt={translatedGearName}
title={translatedGearName}
path={gearImageUrl(gearType, gearId)}
className={styles.gear}
/>
) : null}
{abilities.map((ability, i) => (
<Ability key={i} ability={ability} size={i === 0 ? "MAIN" : "SUB"} />
))}
</>
);
}

View File

@@ -0,0 +1,185 @@
import { RefreshCcw } from "lucide-react";
import * as React from "react";
import {
isRouteErrorResponse,
useLocation,
useRevalidator,
useRouteError,
} from "react-router";
import { useUser } from "~/features/auth/core/user";
import { getSessionId } from "~/utils/session-id";
import {
ERROR_GIRL_IMAGE_PATH,
LOG_IN_URL,
SENDOU_INK_DISCORD_URL,
} from "~/utils/urls";
import { SendouButton } from "./elements/Button";
import { Image } from "./Image";
import { Main } from "./Main";
export function Catcher() {
const error = useRouteError();
const user = useUser();
const { revalidate } = useRevalidator();
const location = useLocation();
React.useEffect(() => {
window.scrollTo(0, 0);
}, []);
// refresh user data to make sure it's up to date (e.g. cookie might have been removed, let's show the prompt to log back in)
const hasRevalidated = React.useRef(false);
React.useEffect(() => {
if (!isRouteErrorResponse(error) || error.status !== 401) return;
if (hasRevalidated.current) return;
hasRevalidated.current = true;
revalidate();
}, [revalidate, error]);
const isNetworkError =
error instanceof Error &&
(error.message.includes("Failed to fetch") ||
error.message.includes("NetworkError") ||
error.message.includes("Load failed"));
if (isNetworkError) {
return (
<ErrorMain>
<ErrorGirlImage />
<h2 className="text-center">Connection error</h2>
<p className="text-center">
The server was temporarily unavailable. This is usually a brief
network issue.
</p>
<div className="mt-4 stack sm items-center">
<RefreshPageButton />
</div>
</ErrorMain>
);
}
if (!isRouteErrorResponse(error)) {
const sessionId = getSessionId();
const errorText = (() => {
if (!(error instanceof Error)) return;
return `Session ID: ${sessionId}\nTime: ${new Date().toISOString()}\nURL: ${location.pathname}${location.search}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`;
})();
return (
<ErrorMain>
<ErrorGirlImage />
<h2 className="text-center">Error happened</h2>
<p className="text-center">
There was an unexpected error. If this keeps happening, please report
it on <a href={SENDOU_INK_DISCORD_URL}>our Discord</a> so it can be
fixed. Include the error message below.
</p>
{errorText ? (
<div className="mt-4 stack sm items-center">
<textarea readOnly defaultValue={errorText} />
<div className="mt-2">
<RefreshPageButton />
</div>
</div>
) : null}
</ErrorMain>
);
}
switch (error.status) {
case 401:
if (!user) {
return (
<ErrorMain>
<h2>Authentication required</h2>
<p>This page requires you to be logged in.</p>
<form action={LOG_IN_URL} method="post" className="mt-2">
<SendouButton type="submit" variant="minimal">
Log in via Discord
</SendouButton>
</form>
</ErrorMain>
);
}
return (
<ErrorMain>
<h2>Error 401 Unauthorized</h2>
<GetHelp />
</ErrorMain>
);
case 403:
return (
<ErrorMain>
<h2>Error 403 Forbidden</h2>
<p className="text-sm text-lighter font-semi-bold">
Your account doesn't have the required permissions to perform this
action.
</p>
<GetHelp />
</ErrorMain>
);
case 404:
return (
<ErrorMain>
<h2>Error {error.status} - Page not found</h2>
<GetHelp />
</ErrorMain>
);
default:
return (
<ErrorMain>
<h2>Error {error.status}</h2>
<GetHelp />
<div className="text-sm text-lighter font-semi-bold">
Please include the session ID and message below if any and an
explanation on what you were doing:
</div>
<pre>
Session ID: {getSessionId()}
{error.data
? `\n${typeof error.data === "string" ? error.data : JSON.stringify(error.data, null, 2)}`
: null}
</pre>
</ErrorMain>
);
}
}
/** Every branch of the error page, marked so tests can assert one is not shown. */
function ErrorMain({ children }: { children: React.ReactNode }) {
return <Main testId="error-page">{children}</Main>;
}
function GetHelp() {
return (
<p className="mt-2">
If you need assistance you can ask for help on{" "}
<a href={SENDOU_INK_DISCORD_URL}>our Discord</a>
</p>
);
}
function ErrorGirlImage() {
return (
<Image
className="m-0-auto"
path={ERROR_GIRL_IMAGE_PATH}
width={292}
height={243.5}
alt=""
/>
);
}
function RefreshPageButton() {
return (
<SendouButton
onPress={() => window.location.reload()}
icon={<RefreshCcw />}
>
Refresh page
</SendouButton>
);
}

View File

@@ -0,0 +1,30 @@
.container {
height: var(--chart-height, 200px);
width: var(--chart-width);
background-color: var(--chart-bg, var(--color-bg-high));
border-radius: var(--radius-box);
}
.tooltip {
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg);
padding: var(--s-1) var(--s-2);
font-weight: var(--weight-semi);
font-size: var(--font-sm);
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.tooltipValue {
margin-inline-start: auto;
min-width: 40px;
}
.dot {
background-color: var(--dot-color);
border-radius: 100%;
width: 12px;
height: 12px;
}

View File

@@ -0,0 +1,335 @@
import {
CategoryScale,
Chart as ChartJS,
type Chart as ChartType,
LinearScale,
LineElement,
PointElement,
Tooltip,
} from "chart.js";
import clsx from "clsx";
import * as React from "react";
import { useRef } from "react";
import { Line } from "react-chartjs-2";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
import { useThemeColors } from "~/hooks/useThemeColors";
import styles from "./Chart.module.css";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
);
export default function Chart({
options,
containerClassName,
headerSuffix,
valueSuffix,
xAxis,
xTicksLimit,
yTicksLimit,
xAbilityLimit,
highlight,
crosshair = false,
}: {
options: [
{
label: React.ReactNode;
data: Array<{ primary: Date | number; secondary: number }>;
},
];
containerClassName?: string;
headerSuffix?: string;
valueSuffix?: string;
xAxis: "linear" | "localTime";
xTicksLimit?: number;
yTicksLimit?: number;
xAbilityLimit?: number;
/** Marks current positions on the curve, each at ability point `x` and stat value `y` (e.g. one per build being compared). */
highlight?: Array<{ x: number; y: number }>;
/** When true, draws dashed guide lines from the hovered point to the x- and y-axes. */
crosshair?: boolean;
}) {
const isHydrated = useHydrated();
// Ref to the Chart.js instance, allows proper cleanup between renders to prevent "Canvas is already in use" errors
const chartRef = useRef<ChartType<"line"> | null>(null);
const chartId = React.useId();
// Chart.js re-fires the external tooltip on every redraw; track the last value to skip redundant state updates
const lastTooltipSignature = useRef<string | null>(null);
const [tooltipData, setTooltipData] = React.useState<{
x: number;
y: number;
flipX: boolean;
flipY: boolean;
items: Array<{
label: React.ReactNode;
value: number | null;
color: string;
}>;
header: string;
} | null>(null);
// Format dates in the tooltip header using the user's locale
const { formatter: headerFormatter } = useDateTimeFormat({
weekday: "short",
day: "numeric",
month: "numeric",
});
// Format dates on the xAxis
const { formatter: scaleFormatter } = useDateTimeFormat({
day: "numeric",
month: "numeric",
});
// Get the chart colors from CSS variables
const colors = useThemeColors({
// bright "high" variants for the curve lines so they stay legible on the dark chart
accentHigh: "--color-text-accent",
infoHigh: "--color-info-high",
secondHigh: "--color-second-high",
// low variants for the highlight marker fills (paired with a light border)
accentLow: "--color-accent-low",
secondLow: "--color-second-low",
border: "--color-border",
borderHigh: "--color-border-high",
text: "--color-text-high",
});
const scaleDefaults = React.useMemo(
() => ({
grid: { color: colors.border },
border: { color: colors.borderHigh },
ticks: { color: colors.text },
}),
[colors.border, colors.borderHigh, colors.text],
);
// Make a color list to use inside ChartData for the borderColor and the external tooltip
const colorList = React.useMemo(
() => [colors.accentHigh, colors.infoHigh, colors.secondHigh],
[colors.accentHigh, colors.infoHigh, colors.secondHigh],
);
// Distinct accent/secondary pair so the highlight markers (e.g. build 1 vs build 2) stay tellable apart in both themes
const markerColors = React.useMemo(
() => [colors.accentLow, colors.secondLow],
[colors.accentLow, colors.secondLow],
);
const datasetColors = React.useCallback(
(i: number) => {
const color = colorList[i % colorList.length];
return {
borderColor: color,
pointBackgroundColor: color,
pointBorderColor: color,
pointHoverBackgroundColor: color,
pointHoverBorderColor: color,
};
},
[colorList],
);
const chartData = React.useMemo(
() => ({
labels: options[0].data.map((_, i) => i),
datasets: [
...options.map((series, i) => ({
label: String(i),
data: series.data.map((d) => d.secondary),
...datasetColors(i),
pointRadius: 0,
pointHoverRadius: 5,
hitRadius: 50,
backgroundColor: "transparent",
})),
...(highlight ?? []).map((point, i) => ({
label: "highlight",
data: options[0].data.map((_, j) => (j === point.x ? point.y : null)),
borderColor: "transparent",
backgroundColor: "transparent",
pointBackgroundColor: markerColors[i % markerColors.length],
pointBorderColor: colors.text,
pointBorderWidth: 2,
pointRadius: 5,
pointHoverRadius: 5,
pointHitRadius: 0,
showLine: false,
// lower order draws on top, so the marker sits above the curves
order: -1,
})),
],
}),
[options, datasetColors, highlight, markerColors, colors.text],
);
// Draws dashed guide lines from the hovered point to the y-axis (left) and x-axis (bottom)
const crosshairPlugin = React.useMemo(
() => ({
id: "crosshair",
afterDatasetsDraw: (chart: ChartType<"line">) => {
const active = chart.tooltip?.getActiveElements() ?? [];
if (active.length === 0) return;
const { ctx, chartArea } = chart;
const { x, y } = active[0].element as { x: number; y: number };
ctx.save();
ctx.beginPath();
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1;
ctx.strokeStyle = colors.borderHigh || "#888";
ctx.moveTo(chartArea.left, y);
ctx.lineTo(x, y);
ctx.lineTo(x, chartArea.bottom);
ctx.stroke();
ctx.restore();
},
}),
[colors.borderHigh],
);
const plugins = React.useMemo(
() => (crosshair ? [crosshairPlugin] : []),
[crosshair, crosshairPlugin],
);
if (!isHydrated) {
return <div className={clsx(styles.container, containerClassName)} />;
}
return (
<div
className={clsx(styles.container, containerClassName)}
style={{ position: "relative" }}
>
<Line
ref={chartRef}
id={chartId}
data={chartData}
plugins={plugins}
options={{
animation: false,
maintainAspectRatio: false,
scales: {
x: {
...scaleDefaults,
max: xAbilityLimit,
// category (not linear) for date axes so ticks only land on real
// data indices instead of fractional positions like 0.5
type: xAxis === "localTime" ? "category" : "linear",
ticks: {
...scaleDefaults.ticks,
maxRotation: 0,
maxTicksLimit: xTicksLimit,
callback: (value) => {
if (xAxis === "localTime") {
const date = options[0].data[value as number]?.primary;
if (date instanceof Date) {
return scaleFormatter.format(date);
}
}
return value;
},
},
},
y: {
...scaleDefaults,
ticks: {
...scaleDefaults.ticks,
maxTicksLimit: yTicksLimit,
},
},
},
plugins: {
// Legend is registered globally by ObjectiveTimeline, opt out explicitly
legend: { display: false },
tooltip: {
enabled: false,
external: ({ chart, tooltip }) => {
if (tooltip.opacity === 0) {
lastTooltipSignature.current = null;
setTooltipData(null);
return;
}
// the highlight marker has no entry in `options` and is non-interactive
const dataPoints = tooltip.dataPoints.filter(
(dp) => dp.datasetIndex < options.length,
);
if (dataPoints.length === 0) {
lastTooltipSignature.current = null;
setTooltipData(null);
return;
}
const x = tooltip.caretX;
const y = tooltip.caretY;
const items = dataPoints.map((dp) => ({
label: options[dp.datasetIndex].label,
value: dp.parsed.y,
color: colorList[dp.datasetIndex % colorList.length],
}));
const rawPrimary =
options[0].data[dataPoints[0].dataIndex]?.primary;
const header =
rawPrimary instanceof Date
? headerFormatter.format(rawPrimary) + (headerSuffix ?? "")
: `${dataPoints[0].parsed.x}${headerSuffix ?? ""}`;
// skip redundant updates from repeated redraws to avoid an infinite render loop
const signature = `${x}|${y}|${header}|${items
.map((i) => `${i.value}:${i.color}`)
.join(",")}`;
if (signature === lastTooltipSignature.current) return;
lastTooltipSignature.current = signature;
setTooltipData({
x,
y,
flipX: x > chart.width / 2,
flipY: y > chart.height / 2,
header,
items,
});
},
},
},
}}
/>
{tooltipData && (
<div
className={styles.tooltip}
style={{
position: "absolute",
left: tooltipData.x,
top: tooltipData.y,
transform: `translate(${
tooltipData.flipX ? "calc(-100% - 8px)" : "8px"
}, ${tooltipData.flipY ? "-100%" : "0"})`,
pointerEvents: "none",
}}
>
<h3 className="text-center text-md">{tooltipData.header}</h3>
{tooltipData.items.map((item, i) => (
<div key={i} className="stack horizontal items-center sm">
<div
className={styles.dot}
style={{ "--dot-color": item.color } as React.CSSProperties}
/>
<div>{item.label}</div>
<div className={styles.tooltipValue}>
{item.value}
{valueSuffix}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Check, Clipboard } from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
interface CopyToClipboardPopoverProps {
url: string;
trigger: React.ReactNode;
}
export function CopyToClipboardPopover({
trigger,
url,
}: CopyToClipboardPopoverProps) {
const { t } = useTranslation(["common"]);
const { copyToClipboard, copySuccess } = useCopyToClipboard();
return (
<SendouPopover trigger={trigger}>
<div className="stack sm">
<input defaultValue={url} readOnly />
<SendouButton
size="miniscule"
variant="minimal"
onPress={() => copyToClipboard(url)}
icon={copySuccess ? <Check /> : <Clipboard />}
>
{t("common:actions.copyToClipboard")}
</SendouButton>
</div>
</SendouPopover>
);
}

View File

@@ -0,0 +1,107 @@
.customThemeSelector {
display: flex;
flex-direction: column;
gap: var(--s-4);
padding: var(--s-4);
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg-medium);
position: relative;
overflow: hidden;
}
.customThemeSelectorSupporter {
display: none;
}
.customThemeSelectorNoSupporter {
position: absolute;
inset: 0;
z-index: 5;
backdrop-filter: blur(3px);
display: flex;
flex-direction: column;
justify-content: center;
}
.customThemeSelectorInfo {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin-bottom: var(--s-4);
text-align: center;
background-color: var(--color-bg);
padding: var(--s-2) var(--s-4);
border-block: var(--border-style);
}
.customThemeSelectorActions {
display: grid;
gap: var(--s-2);
grid-template-columns: 1fr auto;
}
.themeSliders {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.themeSliderRow {
display: grid;
grid-template-columns: 40% auto;
grid-template-rows: auto auto;
gap: var(--s-1) var(--s-2);
align-items: center;
}
.hueSlider {
&::-webkit-slider-runnable-track {
background: linear-gradient(
to right,
oklch(65% 0.15 0),
oklch(65% 0.15 60),
oklch(65% 0.15 120),
oklch(65% 0.15 180),
oklch(65% 0.15 240),
oklch(65% 0.15 300),
oklch(65% 0.15 360)
) !important;
}
&::-moz-range-track {
background: linear-gradient(
to right,
oklch(65% 0.15 0),
oklch(65% 0.15 60),
oklch(65% 0.15 120),
oklch(65% 0.15 180),
oklch(65% 0.15 240),
oklch(65% 0.15 300),
oklch(65% 0.15 360)
) !important;
}
}
.chatColorPreview {
color: oklch(from var(--color-text-accent) l c var(--_chat-h));
}
.themeShare {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.themeShareActions {
display: flex;
gap: var(--s-2);
align-items: center;
}
.themeShareInput {
& input {
flex: 1;
min-width: 0;
}
}

View File

@@ -0,0 +1,507 @@
import { Check, Clipboard, PencilLine } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { CustomTheme } from "~/db/tables-json";
import {
CUSTOM_THEME_VARS,
type CustomThemeVar,
} from "~/features/theme/theme-constants";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import {
ACCENT_CHROMA_MULTIPLIERS,
BASE_CHROMA_MULTIPLIERS,
clampThemeToGamut,
type ThemeInput,
} from "~/utils/oklch-gamut";
import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/zod";
import styles from "./CustomThemeSelector.module.css";
import { Divider } from "./Divider";
import { LinkButton, SendouButton } from "./elements/Button";
import { SendouSwitch } from "./elements/Switch";
import { Label } from "./Label";
const COLOR_SLIDERS = [
{
id: "base-hue",
inputKey: "baseHue",
min: THEME_INPUT_LIMITS.BASE_HUE_MIN,
max: THEME_INPUT_LIMITS.BASE_HUE_MAX,
step: 1,
labelKey: "baseHue",
isHue: true,
},
{
id: "base-chroma",
inputKey: "baseChroma",
min: THEME_INPUT_LIMITS.BASE_CHROMA_MIN,
max: THEME_INPUT_LIMITS.BASE_CHROMA_MAX,
step: 0.001,
labelKey: "baseChroma",
isHue: false,
},
{
id: "accent-hue",
inputKey: "accentHue",
min: THEME_INPUT_LIMITS.ACCENT_HUE_MIN,
max: THEME_INPUT_LIMITS.ACCENT_HUE_MAX,
step: 1,
labelKey: "accentHue",
isHue: true,
},
{
id: "accent-chroma",
inputKey: "accentChroma",
min: THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN,
max: THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX,
step: 0.01,
labelKey: "accentChroma",
isHue: false,
},
] as const;
const RADIUS_SLIDERS = [
{
id: "radius-box",
inputKey: "radiusBox",
min: THEME_INPUT_LIMITS.RADIUS_MIN,
max: THEME_INPUT_LIMITS.RADIUS_MAX,
step: THEME_INPUT_LIMITS.RADIUS_STEP,
labelKey: "boxes",
},
{
id: "radius-field",
inputKey: "radiusField",
min: THEME_INPUT_LIMITS.RADIUS_MIN,
max: THEME_INPUT_LIMITS.RADIUS_MAX,
step: THEME_INPUT_LIMITS.RADIUS_STEP,
labelKey: "fields",
},
{
id: "radius-selector",
inputKey: "radiusSelector",
min: THEME_INPUT_LIMITS.RADIUS_MIN,
max: THEME_INPUT_LIMITS.RADIUS_MAX,
step: THEME_INPUT_LIMITS.RADIUS_STEP,
labelKey: "selectors",
},
] as const;
const BORDER_SLIDERS = [
{
id: "border-width",
inputKey: "borderWidth",
min: THEME_INPUT_LIMITS.BORDER_WIDTH_MIN,
max: THEME_INPUT_LIMITS.BORDER_WIDTH_MAX,
step: THEME_INPUT_LIMITS.BORDER_WIDTH_STEP,
labelKey: "borderWidth",
},
] as const;
const SIZE_SLIDERS = [
{
id: "size-field",
inputKey: "sizeField",
min: THEME_INPUT_LIMITS.SIZE_MIN,
max: THEME_INPUT_LIMITS.SIZE_MAX,
step: THEME_INPUT_LIMITS.SIZE_STEP,
labelKey: "fields",
},
{
id: "size-selector",
inputKey: "sizeSelector",
min: THEME_INPUT_LIMITS.SIZE_MIN,
max: THEME_INPUT_LIMITS.SIZE_MAX,
step: THEME_INPUT_LIMITS.SIZE_STEP,
labelKey: "selectors",
},
{
id: "size-spacing",
inputKey: "sizeSpacing",
min: THEME_INPUT_LIMITS.SIZE_MIN,
max: THEME_INPUT_LIMITS.SIZE_MAX,
step: THEME_INPUT_LIMITS.SIZE_STEP,
labelKey: "spacings",
},
] as const;
type ThemeInputKey =
| (typeof COLOR_SLIDERS)[number]["inputKey"]
| (typeof RADIUS_SLIDERS)[number]["inputKey"]
| (typeof BORDER_SLIDERS)[number]["inputKey"]
| (typeof SIZE_SLIDERS)[number]["inputKey"]
| "chatHue";
const THEME_STRING_KEYS: readonly ThemeInputKey[] = [
...COLOR_SLIDERS.map((s) => s.inputKey),
...RADIUS_SLIDERS.map((s) => s.inputKey),
...BORDER_SLIDERS.map((s) => s.inputKey),
...SIZE_SLIDERS.map((s) => s.inputKey),
"chatHue",
];
function themeInputToString(input: ThemeInput): string {
return THEME_STRING_KEYS.map((key) => {
const value = input[key];
return value === null ? "_" : String(value);
}).join(";");
}
function themeInputFromString(str: string): ThemeInput | null {
const parts = str.split(";");
if (parts.length !== THEME_STRING_KEYS.length) return null;
const raw: Record<string, number | null> = {};
for (let i = 0; i < THEME_STRING_KEYS.length; i++) {
const key = THEME_STRING_KEYS[i];
const part = parts[i].trim();
if (key === "chatHue" && part === "_") {
raw[key] = null;
continue;
}
const num = Number(part);
if (Number.isNaN(num)) return null;
raw[key] = num;
}
const parsed = themeInputSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
}
const DEFAULT_THEME_INPUT: ThemeInput = {
baseHue: 268,
baseChroma: 0.05,
accentHue: 253,
accentChroma: 0.24,
chatHue: null,
radiusBox: 3,
radiusField: 2,
radiusSelector: 2,
borderWidth: 2,
sizeField: 1,
sizeSelector: 1,
sizeSpacing: 1,
};
function themeInputFromCustomTheme(customTheme: CustomTheme): ThemeInput {
return {
baseHue: customTheme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue,
baseChroma:
typeof customTheme["--_base-c-2"] === "number"
? customTheme["--_base-c-2"] / BASE_CHROMA_MULTIPLIERS[2]
: DEFAULT_THEME_INPUT.baseChroma,
accentHue: customTheme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue,
accentChroma:
typeof customTheme["--_acc-c-2"] === "number"
? customTheme["--_acc-c-2"] / ACCENT_CHROMA_MULTIPLIERS[2]
: DEFAULT_THEME_INPUT.accentChroma,
chatHue: customTheme["--_chat-h"],
radiusBox: customTheme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox,
radiusField:
customTheme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField,
radiusSelector:
customTheme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector,
borderWidth:
customTheme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth,
sizeField: customTheme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField,
sizeSelector:
customTheme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector,
sizeSpacing:
customTheme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing,
};
}
function applyThemeInput(input: ThemeInput) {
const clampedTheme = clampThemeToGamut(input);
for (const [key, value] of Object.entries(clampedTheme)) {
document.documentElement.style.setProperty(key, String(value));
}
}
function ThemeSlider({
id,
inputKey,
min,
max,
step,
label,
isHue,
value,
onChange,
}: {
id: string;
inputKey: ThemeInputKey;
min: number;
max: number;
step: number;
label: string;
isHue?: boolean;
value: number;
onChange: (inputKey: ThemeInputKey, value: number) => void;
}) {
return (
<div className={styles.themeSliderRow}>
<Label htmlFor={id}>{label}</Label>
<input
id={id}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(inputKey, Number(e.target.value))}
className={isHue ? styles.hueSlider : undefined}
/>
</div>
);
}
export function CustomThemeSelector({
initialTheme,
isSupporter,
isPersonalTheme,
onSave,
onReset,
hidePatreonInfo,
fetcherState,
}: {
initialTheme: CustomTheme | null | undefined;
isSupporter: boolean;
isPersonalTheme: boolean;
onSave: (themeInput: ThemeInput) => void;
onReset: () => void;
hidePatreonInfo?: boolean;
fetcherState?: "idle" | "submitting" | "loading";
}) {
const { t } = useTranslation(["common"]);
const initialThemeInput = initialTheme
? themeInputFromCustomTheme(initialTheme)
: DEFAULT_THEME_INPUT;
const [themeInput, setThemeInput] =
React.useState<ThemeInput>(initialThemeInput);
const handleSliderChange = (inputKey: ThemeInputKey, value: number) => {
const updatedInput = { ...themeInput, [inputKey]: value };
setThemeInput(updatedInput);
applyThemeInput(updatedInput);
};
const chatHueEnabled = themeInput.chatHue !== null;
const handleChatHueToggle = (isSelected: boolean) => {
const updatedInput = {
...themeInput,
chatHue: isSelected ? 0 : null,
};
setThemeInput(updatedInput);
applyThemeInput(updatedInput);
};
const handleSave = () => {
onSave(themeInput);
};
const handleReset = () => {
setThemeInput(DEFAULT_THEME_INPUT);
CUSTOM_THEME_VARS.forEach((varDef: CustomThemeVar) => {
document.documentElement.style.removeProperty(varDef);
});
onReset();
};
return (
<div className={styles.customThemeSelector}>
{hidePatreonInfo ? null : (
<div
className={
isSupporter
? styles.customThemeSelectorSupporter
: styles.customThemeSelectorNoSupporter
}
>
<div className={styles.customThemeSelectorInfo}>
<p>{t("common:settings.customTheme.patreonText")}</p>
<LinkButton
to="https://www.patreon.com/sendou"
isExternal
size="small"
>
{t("common:settings.customTheme.joinPatreon")}
</LinkButton>
</div>
</div>
)}
<Divider smallText>{t("common:settings.customTheme.colors")}</Divider>
<div className={styles.themeSliders}>
{COLOR_SLIDERS.map((slider) => (
<ThemeSlider
key={slider.id}
id={slider.id}
inputKey={slider.inputKey}
min={slider.min}
max={slider.max}
step={slider.step}
label={t(`common:settings.customTheme.${slider.labelKey}`)}
isHue={slider.isHue}
value={themeInput[slider.inputKey]}
onChange={handleSliderChange}
/>
))}
{isPersonalTheme ? (
<div className="mt-2">
<SendouSwitch
isSelected={chatHueEnabled}
onChange={handleChatHueToggle}
>
{t("common:settings.customTheme.chatHueToggle")}
</SendouSwitch>
</div>
) : null}
{chatHueEnabled && isPersonalTheme ? (
<div className={styles.chatColorPreview}>
<ThemeSlider
id="chat-hue"
inputKey="chatHue"
min={THEME_INPUT_LIMITS.BASE_HUE_MIN}
max={THEME_INPUT_LIMITS.BASE_HUE_MAX}
step={1}
label={t("common:settings.customTheme.chatHue")}
isHue
value={themeInput.chatHue ?? 0}
onChange={handleSliderChange}
/>
</div>
) : null}
</div>
<Divider smallText>{t("common:settings.customTheme.radius")}</Divider>
<div className={styles.themeSliders}>
{RADIUS_SLIDERS.map((slider) => (
<ThemeSlider
key={slider.id}
id={slider.id}
inputKey={slider.inputKey}
min={slider.min}
max={slider.max}
step={slider.step}
label={t(`common:settings.customTheme.${slider.labelKey}`)}
value={themeInput[slider.inputKey]}
onChange={handleSliderChange}
/>
))}
</div>
{isPersonalTheme ? (
<>
<Divider smallText>{t("common:settings.customTheme.sizes")}</Divider>
<div className={styles.themeSliders}>
{SIZE_SLIDERS.map((slider) => (
<ThemeSlider
key={slider.id}
id={slider.id}
inputKey={slider.inputKey}
min={slider.min}
max={slider.max}
step={slider.step}
label={t(`common:settings.customTheme.${slider.labelKey}`)}
value={themeInput[slider.inputKey]}
onChange={handleSliderChange}
/>
))}
</div>
<Divider smallText>
{t("common:settings.customTheme.borders")}
</Divider>
<div className={styles.themeSliders}>
{BORDER_SLIDERS.map((slider) => (
<ThemeSlider
key={slider.id}
id={slider.id}
inputKey={slider.inputKey}
min={slider.min}
max={slider.max}
step={slider.step}
label={t(`common:settings.customTheme.${slider.labelKey}`)}
value={themeInput[slider.inputKey]}
onChange={handleSliderChange}
/>
))}
</div>
</>
) : null}
<Divider />
<ThemeShareInput
themeInput={themeInput}
onImport={(imported) => {
setThemeInput(imported);
applyThemeInput(imported);
}}
/>
<div className={styles.customThemeSelectorActions}>
<SendouButton
isDisabled={
!isSupporter || (fetcherState != null && fetcherState !== "idle")
}
onPress={handleSave}
>
{t("common:actions.save")}
</SendouButton>
<SendouButton
isDisabled={
!isSupporter || (fetcherState != null && fetcherState !== "idle")
}
variant="destructive"
onPress={handleReset}
>
{t("common:actions.reset")}
</SendouButton>
</div>
</div>
);
}
function ThemeShareInput({
themeInput,
onImport,
}: {
themeInput: ThemeInput;
onImport: (input: ThemeInput) => void;
}) {
const { t } = useTranslation(["common"]);
const { copyToClipboard, copySuccess } = useCopyToClipboard();
const themeString = themeInputToString(themeInput);
const handlePaste = async () => {
const text = await navigator.clipboard.readText();
const parsed = themeInputFromString(text);
if (parsed) onImport(parsed);
};
return (
<div className={styles.themeShare}>
<Label htmlFor="theme-input">
{t("common:settings.customTheme.shareCode")}
</Label>
<div className={styles.themeShareActions}>
<input id="theme-input" type="text" value={themeString} readOnly />
<SendouButton
shape="square"
variant="outlined"
icon={copySuccess ? <Check /> : <Clipboard />}
onPress={() => copyToClipboard(themeString)}
aria-label={t("common:settings.customTheme.copy")}
/>
<SendouButton
shape="square"
variant="outlined"
icon={<PencilLine />}
onPress={handlePaste}
aria-label={t("common:settings.customTheme.paste")}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
.divider {
display: flex;
width: 100%;
align-items: center;
color: var(--color-text-accent);
font-size: var(--font-lg);
text-align: center;
&::before,
&::after {
flex: 1;
min-width: 1rem;
border-bottom: 2px solid var(--color-bg-high);
content: "";
}
&:not(:empty)::before {
margin-right: 0.25em;
}
&:not(:empty)::after {
margin-left: 0.25em;
}
}
.smallText {
font-size: var(--font-sm);
}

View File

@@ -0,0 +1,22 @@
import clsx from "clsx";
import styles from "./Divider.module.css";
export function Divider({
children,
className,
smallText,
}: {
children?: React.ReactNode;
className?: string;
smallText?: boolean;
}) {
return (
<div
className={clsx(styles.divider, className, {
[styles.smallText]: smallText,
})}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,27 @@
.pagination {
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
justify-content: center;
align-items: center;
max-width: 20rem;
margin: 0 auto;
margin-top: var(--s-2);
}
.button {
background-color: var(--color-bg);
border-radius: 100%;
padding: var(--s-1);
height: 24px;
width: 24px;
border: var(--border-style);
font-size: var(--font-xs);
color: var(--color-text-high);
}
.buttonActive {
color: var(--color-text-accent);
background-color: var(--color-bg-high);
border-color: var(--color-border-high);
}

View File

@@ -0,0 +1,38 @@
import { clsx } from "clsx";
import { SendouButton } from "~/components/elements/Button";
import styles from "./DotPagination.module.css";
export function DotPagination({
pagesCount,
currentPage,
setPage,
ariaLabelPrefix,
"data-testid": testId,
className,
}: {
pagesCount: number;
currentPage: number;
setPage: (page: number) => void;
ariaLabelPrefix: string;
"data-testid"?: string;
className?: string;
}) {
return (
<div className={clsx(styles.pagination, className)}>
{Array.from({ length: pagesCount }, (_, i) => (
<SendouButton
key={i}
variant="minimal"
aria-label={`${ariaLabelPrefix} page ${i + 1}`}
onPress={() => setPage(i + 1)}
className={clsx(styles.button, {
[styles.buttonActive]: currentPage === i + 1,
})}
data-testid={testId}
>
{i + 1}
</SendouButton>
))}
</div>
);
}

View File

@@ -0,0 +1,8 @@
.dayHeader {
padding: var(--s-2) var(--s-2) var(--s-1);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-high);
text-transform: uppercase;
letter-spacing: 0.05em;
}

View File

@@ -0,0 +1,110 @@
import { isToday, isTomorrow } from "date-fns";
import { useTranslation } from "react-i18next";
import type { SidebarEvent } from "~/features/sidebar/core/sidebar.server";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
import styles from "./EventsList.module.css";
import { Placeholder } from "./Placeholder";
import { ListLink } from "./SideNav";
export function EventsList({
events,
onClick,
}: {
events: SidebarEvent[];
onClick?: () => void;
}) {
const { t, i18n } = useTranslation(["front"]);
const { formatter: dateFormatter } = useDateTimeFormat({
weekday: "long",
month: "numeric",
day: "numeric",
});
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "2-digit",
});
const isHydrated = useHydrated();
if (events.length === 0) {
return (
<div className="text-lighter text-sm p-2">
{t("front:sideNav.noEvents")}
</div>
);
}
if (!isHydrated) {
return <Placeholder />;
}
const getDayKey = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toDateString();
};
const formatDayHeader = (date: Date) => {
if (isToday(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto",
});
const str = rtf.format(0, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
}
if (isTomorrow(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto",
});
const str = rtf.format(1, "day");
return str.charAt(0).toUpperCase() + str.slice(1);
}
return dateFormatter.format(date);
};
const groupedEvents = events.reduce<Record<string, typeof events>>(
(acc, event) => {
const key = getDayKey(event.startsAt);
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(event);
return acc;
},
{},
);
const dayKeys = Object.keys(groupedEvents);
return (
<>
{dayKeys.map((dayKey) => {
const dayEvents = groupedEvents[dayKey];
const firstDate = new Date(dayEvents[0].startsAt * 1000);
return (
<div key={dayKey}>
<div className={styles.dayHeader}>{formatDayHeader(firstDate)}</div>
{dayEvents.map((event) => (
<ListLink
key={`${event.type}-${event.id}`}
to={event.url}
imageUrl={event.logoUrl ?? undefined}
user={event.user ?? undefined}
subtitle={timeFormatter.format(event.startsAt)}
onClick={onClick}
>
{event.scrimStatus === "booked"
? t("front:sideNav.scrimVs", { opponent: event.name })
: event.scrimStatus === "looking"
? t("front:sideNav.lookingForScrim")
: event.scrimStatus === "requestPending"
? t("front:sideNav.scrimRequestPending")
: event.name}
</ListLink>
))}
</div>
);
})}
</>
);
}

View File

@@ -0,0 +1,26 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { countryCodeToTranslatedName } from "~/utils/i18n";
export function Flag({
countryCode,
tiny = false,
}: {
countryCode: string;
tiny?: boolean;
}) {
const { i18n } = useTranslation();
return (
<span
className={clsx(`twf twf-${countryCode.toLowerCase()}`, {
"twf-s": tiny,
})}
data-testid={`flag-${countryCode}`}
title={countryCodeToTranslatedName({
countryCode,
language: i18n.language,
})}
/>
);
}

View File

@@ -0,0 +1,7 @@
.container {
font-size: var(--font-sm);
& > h4 {
color: var(--color-error);
}
}

View File

@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import { useActionData } from "react-router";
import type { Namespace } from "~/modules/i18n/resources.server";
import styles from "./FormErrors.module.css";
export function FormErrors({ namespace }: { namespace: Namespace }) {
const { t } = useTranslation(["common", namespace]);
const actionData = useActionData<{ errors?: string[] }>();
if (!actionData?.errors || actionData.errors.length === 0) {
return null;
}
return (
<div className={styles.container}>
<h4>{t("common:forms.errors.title")}:</h4>
<ol>
{actionData.errors.map((error) => (
<li key={error}>{t(`${namespace}:${error}` as any)}</li>
))}
</ol>
</div>
);
}

View File

@@ -0,0 +1,17 @@
.error {
display: block;
color: var(--color-error);
font-size: var(--font-xs);
margin-block-start: var(--label-margin);
}
.info {
display: block;
color: var(--color-text-high);
font-size: var(--font-xs);
margin-block-start: var(--label-margin);
}
.noMargin {
margin-block-start: 0;
}

View File

@@ -0,0 +1,30 @@
import clsx from "clsx";
import type * as React from "react";
import styles from "./FormMessage.module.css";
export function FormMessage({
children,
type,
className,
spaced = true,
id,
}: {
children: React.ReactNode;
type: "error" | "info";
className?: string;
spaced?: boolean;
id?: string;
}) {
return (
<div
id={id}
className={clsx(
{ [styles.info]: type === "info", [styles.error]: type === "error" },
{ [styles.noMargin]: !spaced },
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,139 @@
import * as React from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { type FetcherWithComponents, useFetcher } from "react-router";
import {
SendouButton,
type SendouButtonProps,
} from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { useHydrated } from "~/hooks/useHydrated";
import invariant from "~/utils/invariant";
import { FormMessage } from "./FormMessage";
import { SubmitButton } from "./SubmitButton";
interface ChildProps {
onPress?: () => void;
type?: "button";
}
export function FormWithConfirm({
fields,
children,
dialogHeading,
description,
submitButtonText,
action,
submitButtonTestId = "submit-button",
submitButtonVariant = "destructive",
fetcher: _fetcher,
isOpen,
onOpenChange,
onConfirm,
}: {
fields?: (
| [name: string, value: string | number]
| readonly [name: string, value: string | number]
)[];
children?: React.ReactElement<ChildProps>;
dialogHeading: string;
/** Optional explanatory text shown below the heading in the confirm dialog */
description?: React.ReactNode;
submitButtonText?: string;
action?: string;
submitButtonTestId?: string;
submitButtonVariant?: SendouButtonProps["variant"];
fetcher?: FetcherWithComponents<any>;
/** Controls the dialog open state. When provided, no child trigger is needed. */
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
/** Confirming runs this callback instead of submitting a form (client only action) */
onConfirm?: () => void;
}) {
const componentsFetcher = useFetcher();
const fetcher = _fetcher ?? componentsFetcher;
const isHydrated = useHydrated();
const { t } = useTranslation(["common"]);
const [internalOpen, setInternalOpen] = React.useState(false);
const formRef = React.useRef<HTMLFormElement>(null);
const id = React.useId();
const isControlled = isOpen !== undefined;
const dialogOpen = isControlled ? isOpen : internalOpen;
const openDialog = () => {
onOpenChange?.(true);
setInternalOpen(true);
};
const closeDialog = () => {
onOpenChange?.(false);
setInternalOpen(false);
};
invariant(!children || React.isValidElement(children));
return (
<>
{isHydrated && !onConfirm
? // using portal here makes nesting this component in another form work
createPortal(
<fetcher.Form
id={id}
className="hidden"
ref={formRef}
method="post"
action={action}
onSubmit={closeDialog}
>
{fields?.map(([name, value]) => (
<input type="hidden" key={name} name={name} value={value} />
))}
</fetcher.Form>,
document.body,
)
: null}
<SendouDialog
isOpen={dialogOpen}
onClose={closeDialog}
onOpenChange={closeDialog}
isDismissable
>
<div className="stack md">
<h2 className="text-md text-center">{dialogHeading}</h2>
{description ? (
<FormMessage type="info">{description}</FormMessage>
) : null}
<div className="stack horizontal md justify-center mt-2">
{onConfirm ? (
<SendouButton
variant={submitButtonVariant}
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
onPress={() => {
closeDialog();
onConfirm();
}}
>
{submitButtonText ?? t("common:actions.delete")}
</SendouButton>
) : (
<SubmitButton
form={id}
variant={submitButtonVariant}
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
>
{submitButtonText ?? t("common:actions.delete")}
</SubmitButton>
)}
</div>
</div>
</SendouDialog>
{children
? React.cloneElement(children, {
onPress: openDialog,
type: "button",
})
: null}
</>
);
}

View File

@@ -0,0 +1,50 @@
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import { InfoPopover } from "~/components/InfoPopover";
import { addFriendCodeSchema } from "~/features/sendouq/q-schemas";
import { SendouForm } from "~/form/SendouForm";
import { navIconUrl, SENDOUQ_PAGE } from "~/utils/urls";
const FC_INFO_IMAGE_URL = navIconUrl("fc-info");
export function FriendCodeInput({
friendCode,
}: {
friendCode?: string | null;
}) {
const { t } = useTranslation(["common"]);
if (friendCode) {
return <div className="font-bold text-center">SW-{friendCode}</div>;
}
return (
<SendouForm
schema={addFriendCodeSchema}
action={SENDOUQ_PAGE}
revalidateRoot
submitButtonText={t("common:actions.save")}
>
{({ FormField }) => (
<div className="stack sm">
<FormField name="friendCode" />
<div className="stack horizontal sm items-center text-lighter text-xs">
{t("common:fc.onceSetStaffOnly")}
<InfoPopover tiny>
<div className="stack sm">
<div className="text-xs font-bold">
{t("common:fc.whereToFind")}
</div>
<Image
path={FC_INFO_IMAGE_URL}
alt={t("common:fc.whereToFind")}
width={320}
/>
</div>
</InfoPopover>
</div>
</div>
)}
</SendouForm>
);
}

View File

@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormMessage } from "~/components/FormMessage";
import { FriendCodeInput } from "~/components/FriendCodeInput";
import { useUser } from "~/features/auth/core/user";
export function FriendCodePopover({ size }: { size?: "small" }) {
const { t } = useTranslation(["common"]);
const user = useUser();
const friendCode = user?.friendCode;
return (
<SendouDialog
key={String(Boolean(friendCode))}
isDismissable
heading={t("common:fc.title")}
trigger={
<SendouButton variant="outlined" size={size}>
{friendCode ? `SW-${friendCode}` : t("common:fc.set")}
</SendouButton>
}
>
<div className="stack md">
<FriendCodeInput friendCode={friendCode} />
<FormMessage type="info">{t("common:fc.altingWarning")}</FormMessage>
{friendCode ? (
<FormMessage type="info">{t("common:fc.changeHelp")}</FormMessage>
) : null}
</div>
</SendouDialog>
);
}

View File

@@ -0,0 +1,120 @@
.root {
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-3);
/* horizontal touch drags scrub, vertical ones keep scrolling the page */
touch-action: pan-y;
}
/* pinned to the shared plot area: both cards pad horizontally by --s-3 and
keep their y-labels inside the same fixed gutter */
.plotOverlay {
position: absolute;
inset: 0 var(--s-3) 0 calc(var(--s-3) + var(--plot-gutter));
pointer-events: none;
}
.scrubLine {
position: absolute;
top: 0;
bottom: 0;
border-left: 2px dotted var(--color-text-high);
}
.readout {
position: absolute;
z-index: 2;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
max-width: 16rem;
padding: var(--s-2) var(--s-2-5);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg);
font-size: var(--font-xs);
transform: translateY(-50%);
white-space: nowrap;
}
.readoutPinned {
top: 0;
left: 50%;
transform: translateX(-50%);
max-width: calc(100% - var(--s-2));
white-space: normal;
}
.readoutTitle {
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.readoutTeam {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.readoutTeamHeader {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-1-5);
}
.readoutTeamName {
font-weight: var(--weight-semi);
}
.readoutScore {
font-variant-numeric: tabular-nums;
}
.readoutPenalty,
.readoutControl {
color: var(--color-text-high);
}
.swatch {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 2px;
&.swatchAlpha {
background-color: var(--color-chart-alpha);
}
&.swatchBravo {
background-color: var(--color-chart-bravo);
}
}
.readoutStatusRow {
display: flex;
align-items: center;
gap: var(--s-1-5);
padding-left: calc(8px + var(--s-1-5));
}
.readoutStatusLabel {
&.statusLabelDead {
color: var(--color-error);
}
&.statusLabelSpecial {
color: var(--color-info);
}
}
.readoutWeapons {
display: flex;
align-items: center;
gap: var(--s-1);
}
.unknownWeapon {
opacity: 0.5;
}

View File

@@ -0,0 +1,377 @@
/**
* A game's two scanned-timeline charts stacked on one shared time axis and
* plot width: per-player status bands above the objective-counter chart.
* Hovering scrubs over both — a dotted cursor line spans the charts and a
* readout next to the cursor shows the moment's elapsed time, match clock,
* scores, penalties, who was in control, who was splatted and who had their
* special ready. The chart's own tooltip is turned off in favor of the
* readout.
*/
import clsx from "clsx";
import { memo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { clamp } from "remeda";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import styles from "./GameTimeline.module.css";
import { Image, WeaponImage } from "./Image";
import {
ObjectiveTimeline,
type ObjectiveTimelineEvent,
} from "./ObjectiveTimeline";
import {
formatElapsed,
smoothPenalties,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
import {
PLAYER_STATUS_TAIL_SECONDS,
PlayerStatusTimeline,
type PlayerStatusTimelineSample,
type PlayerStatusTimelineTeam,
statusSpans,
} from "./PlayerStatusTimeline";
/** Cursor position past this fraction of the plot flips the readout to its left side. */
const READOUT_FLIP_RATIO = 0.55;
const READOUT_CURSOR_GAP_PX = 12;
interface GameTimelineProps {
objectiveEvents?: readonly ObjectiveTimelineEvent[];
playerStatusSamples?: readonly PlayerStatusTimelineSample[];
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
}
interface ScrubPosition {
/** px from the plot area's left edge */
x: number;
/** px from the plot area's top edge */
y: number;
width: number;
/** touch scrubs pin the readout to the top so the finger doesn't hide it */
pinned: boolean;
}
export function GameTimeline({
objectiveEvents,
playerStatusSamples,
teams,
}: GameTimelineProps) {
const [scrub, setScrub] = useState<ScrubPosition | null>(null);
const plotRef = useRef<HTMLDivElement>(null);
const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t);
const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t);
const domain = timelineDomain(objective, samples);
if (!domain) return null;
const handlePointer = (event: React.PointerEvent) => {
const rect = plotRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
setScrub({
x: clamp(event.clientX - rect.left, { min: 0, max: rect.width }),
y: clamp(event.clientY - rect.top, { min: 0, max: rect.height }),
width: rect.width,
pinned: event.pointerType === "touch",
});
};
return (
<div
className={styles.root}
style={
{
"--plot-gutter": `${TIMELINE_PLOT_GUTTER_PX}px`,
} as React.CSSProperties
}
onPointerDown={handlePointer}
onPointerMove={handlePointer}
onPointerLeave={(event) => {
// touch fires a leave as the finger lifts; keep the readout up instead
if (event.pointerType !== "touch") setScrub(null);
}}
>
<TimelineCharts
objectiveEvents={objectiveEvents}
playerStatusSamples={playerStatusSamples}
teams={teams}
/>
<div className={styles.plotOverlay} ref={plotRef}>
{scrub ? (
<ScrubReadout
scrub={scrub}
domain={domain}
objective={objective}
samples={samples}
teams={teams}
/>
) : null}
</div>
</div>
);
}
/** Memoized so scrubbing re-renders only the overlay, not the chart canvas. */
const TimelineCharts = memo(function TimelineCharts({
objectiveEvents,
playerStatusSamples,
teams,
}: GameTimelineProps) {
const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t);
const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t);
const domain = timelineDomain(objective, samples);
if (!domain) return null;
return (
<>
{samples.length > 0 ? (
<PlayerStatusTimeline samples={samples} teams={teams} domain={domain} />
) : null}
{objective.length > 0 ? (
<ObjectiveTimeline
events={objective}
teamLabels={[teams[0].label, teams[1].label]}
domain={domain}
showTooltip={false}
/>
) : null}
</>
);
});
function ScrubReadout({
scrub,
domain,
objective,
samples,
teams,
}: {
scrub: ScrubPosition;
domain: [number, number];
objective: readonly ObjectiveTimelineEvent[];
samples: readonly PlayerStatusTimelineSample[];
teams: GameTimelineProps["teams"];
}) {
const { t } = useTranslation(["common"]);
const [min, max] = domain;
const time = min + (scrub.x / scrub.width) * (max - min);
const objectiveNow = objectiveStateAt(objective, time);
const statusNow = playerStatusAt(samples, time);
const flipped = scrub.x > scrub.width * READOUT_FLIP_RATIO;
return (
<>
<div className={styles.scrubLine} style={{ left: scrub.x }} />
<div
className={clsx(styles.readout, {
[styles.readoutPinned]: scrub.pinned,
})}
style={
scrub.pinned
? undefined
: {
top: scrub.y,
left: flipped ? undefined : scrub.x + READOUT_CURSOR_GAP_PX,
right: flipped
? scrub.width - scrub.x + READOUT_CURSOR_GAP_PX
: undefined,
}
}
>
<div className={styles.readoutTitle}>
{formatElapsed(time)}
{objectiveNow?.clock != null
? ` · ${t("common:objectiveTimeline.timeLeft", {
time: formatElapsed(objectiveNow.clock),
})}`
: null}
</div>
{([0, 1] as const).map((side) => (
<div key={side} className={styles.readoutTeam}>
<div className={styles.readoutTeamHeader}>
<span
className={clsx(
styles.swatch,
side === 0 ? styles.swatchAlpha : styles.swatchBravo,
)}
/>
<span className={styles.readoutTeamName}>
{teams[side].label}
</span>
{objectiveNow ? (
<span className={styles.readoutScore}>
{objectiveNow.scores[side] ?? "?"}
</span>
) : null}
{objectiveNow?.penalties[side] != null ? (
<span className={styles.readoutPenalty}>
{t("common:objectiveTimeline.penalty", {
value: objectiveNow.penalties[side],
})}
</span>
) : null}
{objectiveNow?.control[side] ? (
<span className={styles.readoutControl}>
{t("common:objectiveTimeline.inControl")}
</span>
) : null}
</div>
{statusNow ? (
<>
<StatusWeaponsRow
label={t("common:playerStatusTimeline.splatted")}
kind="dead"
slots={statusNow.dead[side]}
weapons={teams[side].weapons}
/>
<StatusWeaponsRow
label={t("common:playerStatusTimeline.specialReady")}
kind="special"
slots={statusNow.special[side]}
weapons={teams[side].weapons}
/>
</>
) : null}
</div>
))}
</div>
</>
);
}
function StatusWeaponsRow({
label,
kind,
slots,
weapons,
}: {
label: string;
kind: "dead" | "special";
slots: number[];
weapons: (MainWeaponId | null)[];
}) {
if (slots.length === 0) return null;
return (
<div className={styles.readoutStatusRow}>
<span
className={clsx(
styles.readoutStatusLabel,
kind === "dead" ? styles.statusLabelDead : styles.statusLabelSpecial,
)}
>
{label}
</span>
<span className={styles.readoutWeapons}>
{slots.map((slot) =>
weapons[slot] != null ? (
<WeaponImage
key={slot}
weaponSplId={weapons[slot]}
variant="badge"
size={20}
/>
) : (
<Image
key={slot}
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={20}
className={styles.unknownWeapon}
/>
),
)}
</span>
</div>
);
}
function timelineDomain(
objective: readonly ObjectiveTimelineEvent[],
samples: readonly PlayerStatusTimelineSample[],
): [number, number] | null {
const start = Math.min(
objective[0]?.t ?? Number.POSITIVE_INFINITY,
samples[0]?.t ?? Number.POSITIVE_INFINITY,
);
const end = Math.max(
objective[objective.length - 1]?.t ?? Number.NEGATIVE_INFINITY,
samples.length > 0
? samples[samples.length - 1]!.t + PLAYER_STATUS_TAIL_SECONDS
: Number.NEGATIVE_INFINITY,
);
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
return [start, Math.max(end, start + 1)];
}
interface ObjectiveStateAtTime {
/** seconds shown on the match timer at the latest read; null = unreadable */
clock: number | null;
/** last readable count per team at the scrubbed moment */
scores: [number | null, number | null];
penalties: [number | null, number | null];
control: [boolean, boolean];
}
/** State implied by the last objective read at or before `time`, scores carried across unreadable reads. */
function objectiveStateAt(
sorted: readonly ObjectiveTimelineEvent[],
time: number,
): ObjectiveStateAtTime | null {
let index = -1;
for (let i = 0; i < sorted.length; i++) {
if (sorted[i]!.t > time) break;
index = i;
}
if (index === -1) return null;
const scores: [number | null, number | null] = [null, null];
for (let i = 0; i <= index; i++) {
scores[0] = sorted[i]!.data.score[0] ?? scores[0];
scores[1] = sorted[i]!.data.score[1] ?? scores[1];
}
const penalties = ([0, 1] as const).map(
(side) =>
smoothPenalties(
sorted.map((event) => ({
t: event.t,
penalty: event.data.penalty[side],
})),
)[index] ?? null,
) as [number | null, number | null];
const latest = sorted[index]!;
return {
clock: latest.data.time,
scores,
penalties,
control: [latest.data.control[0], latest.data.control[1]],
};
}
interface PlayerStatusAtTime {
/** slot indexes inside a splatted band at the scrubbed moment, per side */
dead: [number[], number[]];
/** slot indexes inside a special-ready band at the scrubbed moment, per side */
special: [number[], number[]];
}
/** Matches the rendered bands: a player counts only while inside a drawn span. */
function playerStatusAt(
sorted: readonly PlayerStatusTimelineSample[],
time: number,
): PlayerStatusAtTime | null {
if (sorted.length === 0) return null;
const activeSlots = (side: 0 | 1, kind: "dead" | "special") =>
[0, 1, 2, 3].filter((slot) =>
statusSpans(sorted, (sample) => sample[kind][side][slot]!).some(
(span) => span.start <= time && time <= span.end,
),
);
return {
dead: [activeSlots(0, "dead"), activeSlots(1, "dead")],
special: [activeSlots(0, "special"), activeSlots(1, "special")],
};
}

View File

@@ -0,0 +1,115 @@
import { useTranslation } from "react-i18next";
import {
SendouSelect,
SendouSelectItem,
SendouSelectItemSection,
} from "~/components/elements/Select";
import { Image } from "~/components/Image";
import { brandIds } from "~/modules/in-game-lists/brand-ids";
import {
clothesGearBrandGrouped,
headGearBrandGrouped,
shoesGearBrandGrouped,
} from "~/modules/in-game-lists/gear-ids";
import type { GearType } from "~/modules/in-game-lists/types";
import { brandImageUrl, gearImageUrl } from "~/utils/urls";
import styles from "./WeaponSelect.module.css";
interface GearSelectProps<Clearable extends boolean | undefined = undefined> {
label?: string;
value?: number | (Clearable extends true ? null : never);
initialValue?: number;
onChange?: (
weaponId: number | (Clearable extends true ? null : never),
) => void;
clearable?: Clearable;
type: GearType;
}
export function GearSelect<Clearable extends boolean | undefined = undefined>({
label,
value,
initialValue,
onChange,
clearable,
type,
}: GearSelectProps<Clearable>) {
const { t } = useTranslation(["common"]);
const items = useGearItems(type);
return (
<SendouSelect
aria-label={!label ? t("common:forms.gearSearch.placeholder") : undefined}
items={items}
label={label}
placeholder={t("common:forms.gearSearch.placeholder")}
search={{
placeholder: t("common:forms.gearSearch.search.placeholder"),
}}
selectedKey={value}
defaultSelectedKey={initialValue}
onSelectionChange={(value) => onChange?.(value as any)}
clearable={clearable}
data-testid={`${type}-gear-select`}
>
{({ key, items: gear, brandId, idx }) => (
<SendouSelectItemSection
className={idx === 0 ? "pt-0-5" : undefined}
heading={t(`game-misc:BRAND_${brandId}` as any)}
headingImgPath={brandImageUrl(brandId)}
key={key}
>
{gear.map(({ id, name }) => (
<SendouSelectItem key={id} id={id} textValue={name}>
<div className={styles.item}>
<Image
path={gearImageUrl(type, id)}
size={24}
alt=""
className={styles.weaponImg}
/>
<span
className={styles.weaponLabel}
data-testid={`gear-select-option-${name}`}
>
{name}
</span>
</div>
</SendouSelectItem>
))}
</SendouSelectItemSection>
)}
</SendouSelect>
);
}
function useGearItems(type: GearType) {
const { t } = useTranslation(["gear", "game-misc"]);
const translationPrefix =
type === "HEAD" ? "H" : type === "CLOTHES" ? "C" : "S";
const groupedGear =
type === "HEAD"
? headGearBrandGrouped
: type === "CLOTHES"
? clothesGearBrandGrouped
: shoesGearBrandGrouped;
const items = brandIds.map((brandId, idx) => {
const items = groupedGear[brandId] || [];
return {
brandId,
key: brandId,
idx,
items: items.map((gearId) => ({
id: gearId,
name: t(`${translationPrefix}_${gearId}` as any),
})),
};
});
return items;
}

View File

@@ -0,0 +1,8 @@
.tierContainer {
display: grid;
}
.tierImg {
grid-column: 1;
grid-row: 1;
}

View File

@@ -0,0 +1,243 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import type { TierName } from "~/features/mmr/mmr-constants";
import type {
MainWeaponId,
ModeShortWithSpecial,
SpecialWeaponId,
StageId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import {
mainWeaponImageUrl,
modeImageUrl,
outlinedFiveStarMainWeaponImageUrl,
outlinedMainWeaponImageUrl,
outlinedTenStarMainWeaponImageUrl,
specialWeaponImageUrl,
stageImageUrl,
subWeaponImageUrl,
TIER_PLUS_URL,
tierImageUrl,
} from "~/utils/urls";
import styles from "./Image.module.css";
interface ImageProps {
path: string;
alt: string;
title?: string;
className?: string;
containerClassName?: string;
width?: number;
height?: number;
size?: number;
style?: React.CSSProperties;
containerStyle?: React.CSSProperties;
testId?: string;
onClick?: () => void;
loading?: "lazy" | "eager";
}
export function Image({
path,
alt,
title,
className,
width,
height,
size,
style,
testId,
containerClassName,
containerStyle,
onClick,
loading = "lazy",
}: ImageProps) {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: Biome v2 migration
<div
title={title}
className={containerClassName}
style={containerStyle}
onClick={onClick}
>
<img
alt={alt}
src={`${path}.avif`}
className={className}
width={size ?? width}
height={size ?? height}
style={style}
draggable="false"
loading={loading}
data-testid={testId}
/>
</div>
);
}
type WeaponWithStars = {
weaponSplId: MainWeaponId;
isFavorite?: boolean | number;
isTenStar?: boolean | number;
};
type WeaponImageProps = (
| { weaponSplId: MainWeaponId; weapon?: never }
| { weapon: WeaponWithStars; weaponSplId?: never }
) & {
variant?: "badge" | "badge-5-star" | "badge-10-star" | "build";
} & Omit<ImageProps, "path" | "alt">;
function resolveWeaponBadgeVariant(weapon: WeaponWithStars) {
if (weapon.isFavorite && weapon.isTenStar) return "badge-10-star" as const;
if (weapon.isFavorite) return "badge-5-star" as const;
return "badge" as const;
}
export function WeaponImage({
weaponSplId: weaponSplIdProp,
weapon,
variant: variantProp,
testId,
title,
...rest
}: WeaponImageProps) {
const { t } = useTranslation(["weapons"]);
const weaponSplId = weapon?.weaponSplId ?? weaponSplIdProp!;
const variant =
variantProp ?? (weapon ? resolveWeaponBadgeVariant(weapon) : "badge");
return (
<Image
{...rest}
alt={title ?? t(`weapons:MAIN_${weaponSplId}`)}
title={title ?? t(`weapons:MAIN_${weaponSplId}`)}
testId={testId}
path={
variant === "badge"
? outlinedMainWeaponImageUrl(weaponSplId)
: variant === "badge-5-star"
? outlinedFiveStarMainWeaponImageUrl(weaponSplId)
: variant === "badge-10-star"
? outlinedTenStarMainWeaponImageUrl(weaponSplId)
: mainWeaponImageUrl(weaponSplId)
}
/>
);
}
type ModeImageProps = {
mode: ModeShortWithSpecial;
} & Omit<ImageProps, "path" | "alt">;
export function ModeImage({ mode, testId, title, ...rest }: ModeImageProps) {
const { t } = useTranslation(["game-misc"]);
return (
<Image
{...rest}
alt={title ?? t(`game-misc:MODE_LONG_${mode}`)}
title={title ?? t(`game-misc:MODE_LONG_${mode}`)}
testId={testId}
path={modeImageUrl(mode)}
/>
);
}
type StageImageProps = {
stageId: StageId;
} & Omit<ImageProps, "path" | "alt" | "title">;
export function StageImage({ stageId, testId, ...rest }: StageImageProps) {
const { t } = useTranslation(["game-misc"]);
return (
<Image
{...rest}
alt={t(`game-misc:STAGE_${stageId}`)}
title={t(`game-misc:STAGE_${stageId}`)}
testId={testId}
path={stageImageUrl(stageId)}
height={rest.height ?? (rest.width ? rest.width * 0.5625 : undefined)}
/>
);
}
type SubWeaponImageProps = {
subWeaponId: SubWeaponId;
} & Omit<ImageProps, "path" | "alt" | "title">;
export function SubWeaponImage({
subWeaponId,
testId,
...rest
}: SubWeaponImageProps) {
const { t } = useTranslation(["weapons"]);
return (
<Image
{...rest}
alt={t(`weapons:SUB_${subWeaponId}`)}
title={t(`weapons:SUB_${subWeaponId}`)}
testId={testId}
path={subWeaponImageUrl(subWeaponId)}
/>
);
}
type SpecialWeaponImageProps = {
specialWeaponId: SpecialWeaponId;
} & Omit<ImageProps, "path" | "alt" | "title">;
export function SpecialWeaponImage({
specialWeaponId,
testId,
...rest
}: SpecialWeaponImageProps) {
const { t } = useTranslation(["weapons"]);
return (
<Image
{...rest}
alt={t(`weapons:SPECIAL_${specialWeaponId}`)}
title={t(`weapons:SPECIAL_${specialWeaponId}`)}
testId={testId}
path={specialWeaponImageUrl(specialWeaponId)}
/>
);
}
type TierImageProps = {
tier: { name: TierName; isPlus: boolean };
} & Omit<ImageProps, "path" | "alt" | "title" | "size" | "height">;
export function TierImage({ tier, className, width = 200 }: TierImageProps) {
const title = `${tier.name}${tier.isPlus ? "+" : ""}`;
const height = width * 0.8675;
return (
<div className={clsx(styles.tierContainer, className)} style={{ width }}>
<Image
path={tierImageUrl(tier.name)}
width={width}
height={height}
alt={title}
title={title}
containerClassName={styles.tierImg}
/>
{tier.isPlus ? (
<Image
path={TIER_PLUS_URL}
width={width}
height={height}
alt={title}
title={title}
containerClassName={styles.tierImg}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,24 @@
.trigger {
border: var(--border-style-high);
border-radius: 100%;
background-color: transparent;
color: var(--color-text);
font-size: var(--font-md);
padding: var(--s-0-5);
width: var(--selector-size);
height: var(--selector-size);
display: flex;
align-items: center;
justify-content: center;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.triggerTiny {
width: var(--selector-size-sm);
height: var(--selector-size-sm);
font-size: var(--font-xs);
}

View File

@@ -0,0 +1,30 @@
import clsx from "clsx";
import { Button } from "react-aria-components";
import { SendouPopover } from "./elements/Popover";
import styles from "./InfoPopover.module.css";
export function InfoPopover({
children,
tiny = false,
className,
}: {
children: React.ReactNode;
tiny?: boolean;
className?: string;
}) {
return (
<SendouPopover
trigger={
<Button
className={clsx(styles.trigger, className, {
[styles.triggerTiny]: tiny,
})}
>
?
</Button>
}
>
{children}
</SendouPopover>
);
}

View File

@@ -0,0 +1,59 @@
.root {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.inputRow {
position: relative;
display: flex;
align-items: center;
& input {
padding-right: calc(var(--field-size) + var(--s-1));
}
& button {
position: absolute;
right: var(--s-1-5);
}
}
.picker {
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg-high);
gap: var(--s-1);
padding-top: var(--s-0-5);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(2.25rem, 1fr));
gap: var(--s-1);
max-height: 300px;
overflow-y: auto;
padding: var(--s-2);
}
.glyph {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border: var(--border-style);
border-radius: var(--radius-selector);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-sm);
cursor: pointer;
&:hover {
background-color: var(--color-bg-higher);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}

View File

@@ -0,0 +1,165 @@
import clsx from "clsx";
import { Languages } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import {
IN_GAME_NAME_CHARACTER_CATEGORIES,
IN_GAME_NAME_MAX_LENGTH,
inGameNameLength,
sanitizeInGameName,
} from "~/features/user-page/in-game-name";
import styles from "./IngameNameInput.module.css";
interface IngameNameInputProps
extends Pick<
React.AriaAttributes,
"aria-invalid" | "aria-describedby" | "aria-errormessage" | "aria-required"
> {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
id?: string;
name?: string;
disabled?: boolean;
placeholder?: string;
}
export function IngameNameInput({
value,
onChange,
onBlur,
id,
name,
disabled,
placeholder,
...ariaProps
}: IngameNameInputProps) {
const { t } = useTranslation(["forms"]);
const [isPickerOpen, setIsPickerOpen] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectionRef = React.useRef({ start: value.length, end: value.length });
const pendingCaretRef = React.useRef<number | null>(null);
React.useEffect(() => {
if (pendingCaretRef.current === null) return;
const caret = pendingCaretRef.current;
pendingCaretRef.current = null;
const input = inputRef.current;
input?.focus();
input?.setSelectionRange(caret, caret);
});
const rememberSelection = () => {
const input = inputRef.current;
if (!input) return;
selectionRef.current = {
start: input.selectionStart ?? value.length,
end: input.selectionEnd ?? value.length,
};
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
const cleaned = sanitizeInGameName(raw);
if (cleaned !== raw) {
const caret = event.target.selectionStart ?? cleaned.length;
pendingCaretRef.current = Math.max(
0,
Math.min(cleaned.length, caret - (raw.length - cleaned.length)),
);
}
onChange(cleaned);
};
const insertCharacter = (character: string) => {
const { start, end } = selectionRef.current;
const before = value.slice(0, start);
const after = value.slice(end);
if (
inGameNameLength(before + after) + inGameNameLength(character) >
IN_GAME_NAME_MAX_LENGTH
) {
return;
}
const caret = before.length + character.length;
pendingCaretRef.current = caret;
selectionRef.current = { start: caret, end: caret };
onChange(before + character + after);
};
return (
<div className={styles.root}>
<div className={styles.inputRow}>
<input
ref={inputRef}
id={id}
name={name}
type="text"
value={value}
disabled={disabled}
placeholder={placeholder}
maxLength={IN_GAME_NAME_MAX_LENGTH}
autoComplete="off"
spellCheck={false}
onChange={handleChange}
onBlur={onBlur}
onSelect={rememberSelection}
{...ariaProps}
/>
<SendouButton
variant="minimal"
size="small"
shape="square"
icon={<Languages />}
isDisabled={disabled}
aria-label={t("forms:inGameName.addCharacter")}
aria-expanded={isPickerOpen}
onPress={() => setIsPickerOpen((open) => !open)}
/>
</div>
{isPickerOpen ? (
<SendouTabs className={styles.picker}>
<SendouTabList>
{IN_GAME_NAME_CHARACTER_CATEGORIES.map((category) => (
<SendouTab key={category.id} id={category.id}>
{t(category.label)}
</SendouTab>
))}
</SendouTabList>
{IN_GAME_NAME_CHARACTER_CATEGORIES.map((category) => (
<SendouTabPanel key={category.id} id={category.id}>
<div className={clsx(styles.grid, "scrollbar")}>
{category.characters.map((character) => (
<button
key={character}
type="button"
className={styles.glyph}
onMouseDown={(event) => event.preventDefault()}
onClick={() => insertCharacter(character)}
>
{character}
</button>
))}
</div>
</SendouTabPanel>
))}
</SendouTabs>
) : null}
</div>
);
}

View File

@@ -0,0 +1,61 @@
.container {
display: flex;
font-size: var(--font-sm);
outline: none;
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
height: var(--field-size);
width: 100%;
& svg {
color: var(--color-text-high);
height: calc(var(--field-size) / 2);
margin: auto;
margin-right: 15px;
}
&:has(input:user-invalid) {
outline: var(--focus-ring-error);
outline-offset: 1px;
}
&:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
input {
border-radius: var(--radius-field);
padding: 0 var(--field-padding);
outline: none;
width: 100%;
background-color: inherit;
color: inherit;
border: none;
&::placeholder {
color: var(--color-text-high);
}
}
}
.readOnly {
pointer-events: none;
cursor: not-allowed;
opacity: 0.5;
outline: none;
}
.addon {
--addon-radius: calc(var(--radius-field) - 2px);
border-radius: var(--addon-radius) 0 0 var(--addon-radius);
display: grid;
background-color: var(--color-bg-high);
color: var(--color-text-high);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
padding-inline: var(--s-2);
place-items: center;
white-space: nowrap;
}

View File

@@ -0,0 +1,86 @@
import clsx from "clsx";
import styles from "./Input.module.css";
export function Input({
name,
id,
className,
minLength,
maxLength,
required,
defaultValue,
leftAddon,
icon,
type,
min,
max,
pattern,
list,
testId,
"aria-label": ariaLabel,
value,
placeholder,
onChange,
onKeyDown,
disableAutoComplete = false,
readOnly,
ref,
}: {
name?: string;
id?: string;
className?: string;
minLength?: number;
maxLength?: number;
required?: boolean;
defaultValue?: string;
leftAddon?: string;
icon?: React.ReactNode;
type?: "number" | "date";
min?: number;
max?: number | string;
pattern?: string;
list?: string;
testId?: string;
"aria-label"?: string;
value?: string;
placeholder?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
disableAutoComplete?: boolean;
readOnly?: boolean;
ref?: React.Ref<HTMLInputElement>;
}) {
return (
<div
className={clsx(styles.container, className, {
[styles.readOnly]: readOnly,
})}
>
{leftAddon ? <div className={styles.addon}>{leftAddon}</div> : null}
<input
ref={ref}
className="in-container"
name={name}
id={id}
minLength={minLength}
maxLength={maxLength}
min={min}
max={max}
defaultValue={defaultValue}
pattern={pattern}
list={list}
data-testid={testId}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
aria-label={ariaLabel}
required={required}
placeholder={placeholder}
type={type}
autoComplete={disableAutoComplete ? "one-time-code" : undefined}
readOnly={readOnly}
/>
{icon}
</div>
);
}

View File

@@ -0,0 +1,29 @@
.container {
display: flex;
align-items: flex-end;
gap: var(--s-2);
margin-block-end: var(--label-margin);
.mb-0 {
margin-block-end: 0;
}
& > label {
margin: 0;
text-box: trim-start cap alphabetic;
}
}
.value {
color: var(--color-text-high);
font-size: var(--font-2xs);
margin-block-start: -5px;
}
.valueWarning {
color: var(--color-warning);
}
.valueError {
color: var(--color-error);
}

View File

@@ -0,0 +1,49 @@
import clsx from "clsx";
import styles from "./Label.module.css";
type LabelProps = Pick<
React.DetailedHTMLProps<
React.LabelHTMLAttributes<HTMLLabelElement>,
HTMLLabelElement
>,
"children" | "htmlFor"
> & {
valueLimits?: {
current: number;
max: number;
};
required?: boolean;
className?: string;
labelClassName?: string;
spaced?: boolean;
};
export function Label({
valueLimits,
required,
children,
htmlFor,
className,
labelClassName,
spaced = true,
}: LabelProps) {
return (
<div className={clsx(styles.container, className, { "mb-0": !spaced })}>
<label htmlFor={htmlFor} className={labelClassName}>
{children} {required && <span className="text-error">*</span>}
</label>
{valueLimits ? (
<div className={clsx(styles.value, lengthWarning(valueLimits))}>
{valueLimits.current}/{valueLimits.max}
</div>
) : null}
</div>
);
}
function lengthWarning(valueLimits: NonNullable<LabelProps["valueLimits"]>) {
if (valueLimits.current > valueLimits.max) return styles.valueError;
if (valueLimits.current / valueLimits.max >= 0.9) return styles.valueWarning;
return;
}

View File

@@ -0,0 +1,56 @@
import clsx from "clsx";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
interface LocaleTimeProps {
/** The date to render. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */
date: Date | number;
/** Formatting options forwarded to `Intl.DateTimeFormat`. Combined with the user's locale and hour cycle preferences. */
options: Intl.DateTimeFormatOptions;
/** Optional extra class names appended to the rendered `<time>` element. */
className?: string;
/** When `true`, renders inline; otherwise the element is displayed as a block. Defaults to block. */
inline?: boolean;
/** Optional test id forwarded to the rendered `<time>` element. */
"data-testid"?: string;
}
/**
* Renders a `<time>` element with the given date formatted according to the user's locale preferences.
*
* During SSR and before the user's locale preference has loaded the formatted text is hidden
* (via `invisible`) while still reserving one line of height to avoid layout shift on hydration.
* The `dateTime` attribute is always set to the ISO string for machine readability and a11y.
*/
export function LocaleTime({
date,
options,
className,
inline,
"data-testid": testId,
}: LocaleTimeProps) {
const { formatter, isLoaded } = useDateTimeFormat(options);
const dateObject =
typeof date === "number" ? databaseTimestampToDate(date) : date;
return (
<time
// Hydration warnings are suppressed because callers may pass a live "now" value (e.g. a clock)
// whose server and client render instants differ slightly, which would otherwise mismatch the
// `dateTime` attribute. For fixed dates the ISO string is deterministic, so nothing real is masked.
suppressHydrationWarning
data-testid={testId}
dateTime={dateObject.toISOString()}
className={clsx(
{
block: !inline,
invisible: !isLoaded,
},
className,
)}
>
{formatter.format(dateObject)}
</time>
);
}

View File

@@ -0,0 +1,56 @@
import clsx from "clsx";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
interface LocaleTimeRangeProps {
/** Start of the range. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */
from: Date | number;
/** End of the range. Accepts a `Date` or a database timestamp (number), which is converted via `databaseTimestampToDate`. */
to: Date | number;
/** Formatting options forwarded to `Intl.DateTimeFormat`. Combined with the user's locale and hour cycle preferences. */
options: Intl.DateTimeFormatOptions;
/** Optional extra class names appended to the rendered element. */
className?: string;
/** When `true`, renders inline; otherwise the element is displayed as a block. Defaults to block. */
inline?: boolean;
/** Optional test id forwarded to the rendered element. */
"data-testid"?: string;
}
/**
* Renders the given date range formatted according to the user's locale preferences,
* using `Intl.DateTimeFormat.prototype.formatRange` for locale-aware separators and
* collapsing of shared parts (e.g. the year when both bounds share it).
*
* During SSR and before the user's locale preference has loaded the formatted text is hidden
* (via `invisible`) while still reserving one line of height to avoid layout shift on hydration.
*/
export function LocaleTimeRange({
from,
to,
options,
className,
inline,
"data-testid": testId,
}: LocaleTimeRangeProps) {
const { formatter, isLoaded } = useDateTimeFormat(options);
const fromDate =
typeof from === "number" ? databaseTimestampToDate(from) : from;
const toDate = typeof to === "number" ? databaseTimestampToDate(to) : to;
return (
<span
data-testid={testId}
className={clsx(
{
block: !inline,
invisible: !isLoaded,
},
className,
)}
>
{formatter.formatRange(fromDate, toDate)}
</span>
);
}

View File

@@ -0,0 +1,31 @@
.main {
container-type: inline-size;
padding: var(--layout-main-padding);
margin-bottom: var(--s-32);
min-height: calc(100dvh - var(--layout-nav-height));
/* Fill the whole content area (sidebars already excluded by the flex
layout) while staying a query container, so a descendant can break out
of the page max-width and size against the full width via cqw units. */
&[data-main-breakout] {
max-width: none;
}
}
.normal {
width: 100%;
max-width: 48rem;
margin-inline: auto;
}
.narrow {
width: 100%;
max-width: 24rem;
margin-inline: auto;
}
.wide {
width: 100%;
max-width: 72rem;
margin-inline: auto;
}

View File

@@ -0,0 +1,62 @@
import clsx from "clsx";
import type * as React from "react";
import styles from "./Main.module.css";
export const Main = ({
children,
className,
classNameOverwrite,
halfWidth,
bigger,
breakoutContainer,
style,
testId,
}: {
children: React.ReactNode;
className?: string;
classNameOverwrite?: string;
halfWidth?: boolean;
bigger?: boolean;
breakoutContainer?: boolean;
style?: React.CSSProperties;
testId?: string;
}) => {
return (
<main
className={
classNameOverwrite
? clsx(classNameOverwrite, {
[styles.narrow]: halfWidth,
})
: clsx(
styles.main,
styles.normal,
{
[styles.narrow]: halfWidth,
[styles.wide]: bigger,
},
className,
)
}
data-main-breakout={breakoutContainer || undefined}
data-testid={testId}
style={style}
>
{children}
</main>
);
};
export { styles as mainStyles };
export const containerClassName = (width: "narrow" | "normal" | "wide") => {
if (width === "narrow") {
return styles.narrow;
}
if (width === "wide") {
return styles.wide;
}
return styles.normal;
};

View File

@@ -0,0 +1,74 @@
.stageRow {
display: flex;
width: 100%;
align-items: stretch;
gap: var(--s-2);
}
.stageNameRow {
display: flex;
flex-direction: column;
flex-grow: 1;
align-items: center;
justify-content: space-between;
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
gap: var(--s-2);
padding-block: var(--s-1-5);
padding-inline: var(--s-3);
}
@container (width >= 560px) {
.stageNameRow {
flex-direction: row;
}
}
.stageImage {
border-radius: var(--radius-box);
height: 100%;
object-fit: cover;
}
.modeButtonsContainer {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-1);
}
.modeButton {
padding: 0;
height: var(--field-size-sm);
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
border: var(--border-style);
border-radius: var(--radius-full);
background-color: var(--color-bg);
color: var(--color-accent);
opacity: 1 !important;
outline: initial;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&.selected {
border: 2px solid transparent;
background-color: var(--color-bg-higher);
}
&.preselected {
border: var(--border-style-accent);
background-color: var(--color-bg-higher);
}
}
.mode:not(.selected, .preselected) {
filter: grayscale(100%) brightness(50%);
}

View File

@@ -0,0 +1,373 @@
import clsx from "clsx";
import { ArrowLeft, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import type { Tables } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { split, startsWith } from "~/utils/strings";
import { assertType } from "~/utils/types";
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
import { SendouButton } from "./elements/Button";
import styles from "./MapPoolSelector.module.css";
export type MapPoolSelectorProps = {
mapPool: MapPool;
preselectedMapPool?: MapPool;
handleRemoval?: () => void;
handleMapPoolChange: (
mapPool: MapPool,
event?: Pick<Tables["CalendarEvent"], "id" | "name">,
) => void;
className?: string;
title?: string;
modesToInclude?: ModeShort[];
info?: React.ReactNode;
footer?: React.ReactNode;
/** Enables clear button, template selection, and toggling a whole stage */
allowBulkEdit?: boolean;
hideBanned?: boolean;
};
export function MapPoolSelector({
mapPool,
preselectedMapPool,
handleMapPoolChange,
handleRemoval,
className,
title,
modesToInclude,
info,
footer,
allowBulkEdit = false,
hideBanned = false,
}: MapPoolSelectorProps) {
const { t } = useTranslation();
const [template, setTemplate] = React.useState<MapPoolTemplateValue>(
detectTemplate(mapPool),
);
const handleStageModesChange = (newMapPool: MapPool) => {
setTemplate(detectTemplate(newMapPool));
handleMapPoolChange(newMapPool);
};
const handleClear = () => {
setTemplate("none");
handleMapPoolChange(MapPool.EMPTY);
};
const handleTemplateChange = (template: MapPoolTemplateValue) => {
setTemplate(template);
if (template === "none") {
return;
}
if (startsWith(template, "preset:")) {
const [, presetId] = split(template, ":");
handleMapPoolChange(MapPool[presetId]);
return;
}
assertType<never, typeof template>();
};
return (
<fieldset className={className}>
{Boolean(title) && <legend>{title}</legend>}
{Boolean(handleRemoval || allowBulkEdit) && (
<div className="stack horizontal sm justify-end">
{handleRemoval && (
<SendouButton variant="minimal" onPress={handleRemoval}>
{t("actions.remove")}
</SendouButton>
)}
{allowBulkEdit && (
<SendouButton
variant="minimal-destructive"
isDisabled={mapPool.isEmpty()}
onPress={handleClear}
>
{t("actions.clear")}
</SendouButton>
)}
</div>
)}
<div className="stack md">
{allowBulkEdit && (
<MapPoolTemplateSelect
value={template}
handleChange={handleTemplateChange}
/>
)}
{info}
<MapPoolStages
mapPool={mapPool}
handleMapPoolChange={handleStageModesChange}
allowBulkEdit={allowBulkEdit}
modesToInclude={modesToInclude}
preselectedMapPool={preselectedMapPool}
hideBanned={hideBanned}
/>
{footer}
</div>
</fieldset>
);
}
export type MapPoolStagesProps = {
mapPool: MapPool;
handleMapPoolChange?: (newMapPool: MapPool) => void;
allowBulkEdit?: boolean;
modesToInclude?: ModeShort[];
preselectedMapPool?: MapPool;
hideBanned?: boolean;
};
export function MapPoolStages({
mapPool,
handleMapPoolChange,
allowBulkEdit = false,
modesToInclude,
preselectedMapPool,
hideBanned = false,
}: MapPoolStagesProps) {
const { t } = useTranslation(["game-misc", "common"]);
const isPresentational = !handleMapPoolChange;
const stageRowIsVisible = (stageId: StageId) => {
if (!isPresentational) return true;
return mapPool.hasStage(stageId);
};
const handleModeChange = ({
mode,
stageId,
}: {
mode: ModeShort;
stageId: StageId;
}) => {
const newMapPool = mapPool.parsed[mode].includes(stageId)
? new MapPool({
...mapPool.parsed,
[mode]: mapPool.parsed[mode].filter((id) => id !== stageId),
})
: new MapPool({
...mapPool.parsed,
[mode]: [...mapPool.parsed[mode], stageId],
});
handleMapPoolChange?.(newMapPool);
};
const handleStageClear = (stageId: StageId) => {
const newMapPool = new MapPool({
TW: mapPool.parsed.TW.filter((id) => id !== stageId),
SZ: mapPool.parsed.SZ.filter((id) => id !== stageId),
TC: mapPool.parsed.TC.filter((id) => id !== stageId),
RM: mapPool.parsed.RM.filter((id) => id !== stageId),
CB: mapPool.parsed.CB.filter((id) => id !== stageId),
});
handleMapPoolChange?.(newMapPool);
};
const handleStageAdd = (stageId: StageId) => {
const newMapPool = new MapPool({
TW: [...mapPool.parsed.TW, stageId],
SZ: [...mapPool.parsed.SZ, stageId],
TC: [...mapPool.parsed.TC, stageId],
RM: [...mapPool.parsed.RM, stageId],
CB: [...mapPool.parsed.CB, stageId],
});
handleMapPoolChange?.(newMapPool);
};
const id = React.useId();
return (
<div className="stack md">
{stageIds.filter(stageRowIsVisible).map((stageId) => (
<div key={stageId} className={styles.stageRow}>
<Image
className={styles.stageImage}
alt=""
path={stageImageUrl(stageId)}
width={80}
height={45}
/>
{/** biome-ignore lint/a11y/useSemanticElements: todo */}
<div
className={styles.stageNameRow}
role="group"
aria-labelledby={`${id}-stage-name-${stageId}`}
>
<div id={`${id}-stage-name-${stageId}`}>
{t(`game-misc:STAGE_${stageId}`)}
</div>
<div className={styles.modeButtonsContainer}>
{modesShort
.filter(
(mode) => !modesToInclude || modesToInclude.includes(mode),
)
.map((mode) => {
const selected = mapPool.has({ stageId, mode });
if (isPresentational && !selected) return null;
if (isPresentational && selected) {
return (
<Image
key={mode}
className={clsx(styles.mode, {
[styles.selected]: selected,
})}
title={t(`game-misc:MODE_LONG_${mode}`)}
alt={t(`game-misc:MODE_LONG_${mode}`)}
path={modeImageUrl(mode)}
width={33}
height={33}
/>
);
}
const preselected = preselectedMapPool?.has({
stageId,
mode,
});
return (
<button
key={mode}
className={clsx(styles.modeButton, {
[styles.selected]: selected,
[styles.preselected]: preselected,
invisible:
hideBanned && BANNED_MAPS[mode].includes(stageId),
})}
onClick={() => handleModeChange?.({ mode, stageId })}
type="button"
title={t(`game-misc:MODE_LONG_${mode}`)}
aria-describedby={`${id}-stage-name-${stageId}`}
aria-pressed={selected}
disabled={preselected}
>
<Image
className={clsx(styles.mode, {
[styles.selected]: selected,
[styles.preselected]: preselected,
})}
alt={t(`game-misc:MODE_LONG_${mode}`)}
path={modeImageUrl(mode)}
width={20}
height={20}
/>
</button>
);
})}
{!isPresentational &&
allowBulkEdit &&
(mapPool.hasStage(stageId) ? (
<SendouButton
shape="circle"
key="clear"
onPress={() => handleStageClear(stageId)}
icon={<X />}
variant="minimal"
aria-label={t("common:actions.remove")}
size="small"
/>
) : (
<SendouButton
shape="circle"
key="select-all"
onPress={() => handleStageAdd(stageId)}
icon={<ArrowLeft />}
variant="minimal"
aria-label={t("common:actions.selectAll")}
size="small"
/>
))}
</div>
</div>
</div>
))}
</div>
);
}
type MapModePresetId = "ANARCHY" | "ALL" | ModeShort;
const presetIds: MapModePresetId[] = ["ANARCHY", "ALL", ...modesShort];
type MapPoolTemplateValue = "none" | `preset:${MapModePresetId}`;
function detectTemplate(mapPool: MapPool): MapPoolTemplateValue {
for (const presetId of presetIds) {
if (MapPool[presetId].serialized === mapPool.serialized) {
return `preset:${presetId}`;
}
}
return "none";
}
type MapPoolTemplateSelectProps = {
value: MapPoolTemplateValue;
handleChange: (newValue: MapPoolTemplateValue) => void;
recentEvents?: Pick<Tables["CalendarEvent"], "id" | "name">[];
};
function MapPoolTemplateSelect({
handleChange,
value,
recentEvents,
}: MapPoolTemplateSelectProps) {
const { t } = useTranslation(["game-misc", "common"]);
return (
<label className="stack sm">
{t("common:maps.template")}
<select
value={value}
onChange={(e) => {
handleChange(e.currentTarget.value as MapPoolTemplateValue);
}}
>
<option value="none">{t("common:maps.template.none")}</option>
<optgroup label={t("common:maps.template.presets")}>
{(["ANARCHY", "ALL"] as const).map((presetId) => (
<option key={presetId} value={`preset:${presetId}`}>
{t(`common:maps.template.preset.${presetId}`)}
</option>
))}
{modesShort.map((mode) => (
<option key={mode} value={`preset:${mode}`}>
{t("common:maps.template.preset.onlyMode", {
modeName: t(`game-misc:MODE_LONG_${mode}`),
})}
</option>
))}
</optgroup>
{recentEvents && recentEvents.length > 0 && (
<optgroup label={t("common:maps.template.yourRecentEvents")}>
{recentEvents.map((event) => (
<option key={event.id} value={`recent-event:${event.id}`}>
{event.name}
</option>
))}
</optgroup>
)}
</select>
</label>
);
}

View File

@@ -0,0 +1,43 @@
import MarkdownToJsx from "markdown-to-jsx";
import * as React from "react";
// note: markdown-to-jsx also handles these, this is just to prevent them from appearing as plain text
const DANGEROUS_HTML_TAGS_REGEX =
/<(style|link|head|iframe|script|title|textarea|xmp|noembed|noframes|plaintext)[\s\S]*?<\/\1>|<(style|link|head|iframe|script|title|textarea|xmp|noembed|noframes|plaintext)[^>]*\/>/gi;
const CSS_URL_REGEX = /url\s*\([^)]*\)/gi;
export function Markdown({ children }: { children: string }) {
const sanitized = children
.replace(DANGEROUS_HTML_TAGS_REGEX, "")
.replace(/style\s*=\s*("[^"]*"|'[^']*')/gi, (_match, value) => {
const sanitized = value.replace(CSS_URL_REGEX, "");
return `style=${sanitized}`;
})
.replace(/ +$/gm, "");
return (
<MarkdownToJsx
options={{
wrapper: React.Fragment,
overrides: {
br: { component: () => <br /> },
hr: { component: () => <hr /> },
img: {
component: ({
children: _,
...props
}: React.ComponentProps<"img"> & {
children?: React.ReactNode;
}) => (
// biome-ignore lint/a11y/useAltText: parsed markdown, so we can't guarantee alt text is present
<img {...props} referrerPolicy="no-referrer" />
),
},
},
}}
>
{sanitized}
</MarkdownToJsx>
);
}

View File

@@ -0,0 +1,390 @@
.mobileNav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 19;
}
@media screen and (min-width: 600px) {
.mobileNav {
display: none;
}
}
.tabBar {
display: flex;
justify-content: space-around;
align-items: center;
height: calc(var(--layout-nav-height) + env(safe-area-inset-bottom));
background-color: var(--color-bg-nav);
border-top: 1.5px solid var(--color-border);
padding: 0 var(--s-4);
padding-bottom: env(safe-area-inset-bottom);
}
.tab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0;
height: 100%;
aspect-ratio: 1 / 1;
background: none;
border: none;
border-radius: var(--radius-field);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
cursor: pointer;
text-decoration: none;
transition: color 0.15s;
&:focus-visible {
outline: var(--focus-ring);
outline-offset: -1px;
}
&:hover,
&[data-active="true"] {
color: var(--color-text-accent);
}
}
.tabIcon {
position: relative;
width: 24px;
height: 24px;
& svg {
width: 24px;
height: 24px;
}
}
.sideNavEmpty {
padding: var(--s-4);
text-align: center;
color: var(--color-text-high);
font-size: var(--font-xs);
}
.panelOverlay {
position: fixed;
inset: 0;
bottom: var(--layout-nav-height);
z-index: 18;
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(10px);
&[data-entering] {
animation: fade-in 200ms ease-out;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 85%;
background-color: var(--color-bg);
border-radius: var(--radius-box) var(--radius-box) 0 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding-block-end: env(safe-area-inset-bottom);
&[data-entering] {
animation: slide-up 200ms ease-out;
}
}
@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.panelHeader {
position: sticky;
top: 0;
display: flex;
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-4);
background-color: var(--color-bg-high);
border-bottom: 1.5px solid var(--color-border);
z-index: 1;
flex-shrink: 0;
color: var(--color-text-high);
min-height: var(--layout-nav-height);
}
.panelTitle {
font-size: var(--font-xs);
font-weight: var(--weight-bold);
}
.panelContent {
flex: 1;
overflow-y: auto;
padding: var(--s-2);
display: flex;
flex-direction: column;
}
.panelDialog {
outline: none;
display: flex;
flex-direction: column;
height: 100%;
}
.menuOverlay {
position: fixed;
inset: 0;
z-index: 20;
background-color: var(--color-bg);
overflow-y: auto;
display: flex;
flex-direction: column;
&[data-entering] {
animation: fade-in 200ms ease-out;
}
}
.menuHeader {
display: flex;
align-items: center;
gap: var(--s-2);
padding-inline: var(--s-4);
background-color: var(--color-bg-high);
border-bottom: 1.5px solid var(--color-border);
border-top: 1.5px solid var(--color-border);
flex-shrink: 0;
color: var(--color-text-high);
min-height: var(--layout-nav-height);
.panelDialog > &:first-child {
border-top: none;
padding-block-start: env(safe-area-inset-top);
}
}
.menuHeaderActions {
display: flex;
align-items: center;
gap: var(--s-2);
margin-inline-start: auto;
}
.panelCloseButton {
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-error);
padding: 0;
height: var(--field-size);
aspect-ratio: 1 / 1;
border-radius: var(--radius-field);
margin-inline-start: auto;
&:hover {
background-color: var(--color-bg-higher);
}
}
.panelIconContainer {
border-radius: var(--radius-field);
}
.streamsList {
list-style: none;
margin: 0;
padding: var(--s-4);
padding-block-end: calc(env(safe-area-inset-bottom) + var(--s-4));
& a:not(:first-child) {
padding-block: var(--s-2);
}
& a:first-child {
padding-block-start: 0;
}
}
.navGrid {
list-style: none;
margin: 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--s-3);
padding: var(--s-4);
}
.navItem {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
text-decoration: none;
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-align: center;
&:hover {
color: var(--color-text-accent);
}
}
.navItemImage {
width: var(--field-size-lg);
aspect-ratio: 1 / 1;
border-radius: var(--radius-field);
background-color: var(--color-bg-higher);
display: grid;
place-items: center;
}
.youPanelUserRow {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
padding: var(--s-1) var(--s-2);
}
.youPanelUser {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2);
text-decoration: none;
color: var(--color-text);
border-radius: var(--radius-field);
&:hover {
background-color: var(--color-bg-high);
}
}
.youPanelUsername {
font-size: var(--font-md);
font-weight: var(--weight-bold);
}
.youPanelSettingsButton {
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: var(--color-text-high);
border-radius: var(--radius-field);
height: var(--field-size);
aspect-ratio: 1 / 1;
&:hover {
background-color: var(--color-bg-high);
color: var(--color-text);
}
& svg {
width: 18px;
height: 18px;
}
}
.panelSectionLink {
display: flex;
align-items: center;
gap: 2px;
width: fit-content;
margin-top: auto;
margin-inline: auto;
margin-bottom: var(--s-4);
font-size: var(--font-2xs);
color: var(--color-text-high);
text-decoration: none;
height: var(--selector-size);
padding: 0 var(--s-3);
background-color: var(--color-bg-high);
border-radius: var(--radius-selector);
& svg {
stroke-width: 3;
}
&:hover {
color: var(--color-text);
background-color: var(--color-bg-higher);
}
}
.tabBadge {
position: absolute;
top: -4px;
right: -8px;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
min-width: 16px;
height: 16px;
padding: 0 var(--s-0-5);
border-radius: 8px;
display: grid;
place-items: center;
pointer-events: none;
line-height: 1;
&.tabBadgeLeft {
right: auto;
left: -8px;
}
}
.noAnimation {
&[data-entering] {
animation: none;
}
}
.ghostTabBar {
position: fixed;
bottom: calc(0px - var(--layout-nav-height));
left: 0;
right: 0;
height: calc(var(--layout-nav-height) + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
display: flex;
justify-content: space-around;
align-items: center;
z-index: 100;
}
.ghostTab {
height: 100%;
flex: 1;
background: none;
border: none;
cursor: pointer;
opacity: 0;
padding: 0;
}

View File

@@ -0,0 +1,662 @@
import clsx from "clsx";
import {
Calendar,
ChevronRight,
Heart,
LogIn,
Menu,
MessageSquare,
Settings,
Tv,
User,
Users,
X,
} from "lucide-react";
import * as React from "react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import type { RootLoaderData } from "~/root";
import {
EVENTS_PAGE,
FRIENDS_PAGE,
navIconUrl,
SENDOU_INK_BASE_URL,
SETTINGS_PAGE,
SUPPORT_PAGE,
userPage,
} from "~/utils/urls";
import { Avatar } from "./Avatar";
import { EventsList } from "./EventsList";
import { LinkButton } from "./elements/Button";
import { Image } from "./Image";
import { ChatSidebar } from "./layout/ChatSidebar";
import { LogInButtonContainer } from "./layout/LogInButtonContainer";
import {
NotificationContent,
useNotifications,
} from "./layout/NotificationPopover";
import { navItems } from "./layout/nav-items";
import styles from "./MobileNav.module.css";
import { NotificationDot } from "./NotificationDot";
import { ShareUrlButton } from "./ShareUrlButton";
import { StreamListItems } from "./StreamListItems";
type SidebarData = RootLoaderData["sidebar"] | undefined;
type PanelType = "closed" | "menu" | "friends" | "tourneys" | "chat" | "you";
export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
const [activePanel, setActivePanel] = React.useState<PanelType>("closed");
const previousPanelRef = React.useRef<PanelType>("closed");
const user = useUser();
const { showUnseenDot } = useNotifications();
const chatContext = useChatContext();
const layoutSize = useLayoutSize();
const hasFriendInSendouQ =
sidebarData?.friends.some((f) => f.subtitle === SENDOUQ_ACTIVITY_LABEL) ??
false;
const unseenFriendRequests = useUnseenFriendRequests(
sidebarData?.incomingFriendRequestIds ?? [],
);
const skipAnimation = previousPanelRef.current !== "closed";
const closePanel = () => {
previousPanelRef.current = activePanel;
setActivePanel("closed");
};
const handleTabPress = (panel: PanelType) => {
if (activePanel === panel) {
if (panel === "chat") {
chatContext?.setChatOpen(false);
}
previousPanelRef.current = activePanel;
setActivePanel("closed");
return;
}
if (activePanel === "chat") {
chatContext?.setChatOpen(false);
}
if (panel === "chat") {
chatContext?.setChatOpen(true);
}
previousPanelRef.current = activePanel;
setActivePanel(panel);
};
const closeChatPanel = () => {
chatContext?.setChatOpen(false);
closePanel();
};
return (
<div className={styles.mobileNav}>
{activePanel === "menu" ? (
<MenuOverlay
streams={sidebarData?.streams ?? []}
savedTournamentIds={sidebarData?.savedTournamentIds}
onClose={closePanel}
onTabPress={handleTabPress}
isLoggedIn={Boolean(user)}
skipAnimation={skipAnimation}
/>
) : null}
{activePanel === "friends" ? (
<FriendsPanel
friends={sidebarData?.friends ?? []}
onClose={closePanel}
onTabPress={handleTabPress}
isLoggedIn={Boolean(user)}
skipAnimation={skipAnimation}
/>
) : null}
{activePanel === "tourneys" ? (
<TourneysPanel
events={sidebarData?.events ?? []}
onClose={closePanel}
onTabPress={handleTabPress}
isLoggedIn={Boolean(user)}
skipAnimation={skipAnimation}
/>
) : null}
{activePanel === "you" ? (
<YouPanel
onClose={closePanel}
onTabPress={handleTabPress}
isLoggedIn={Boolean(user)}
skipAnimation={skipAnimation}
/>
) : null}
{chatContext?.chatOpen && layoutSize === "mobile" ? (
<ChatPanel
onClose={closeChatPanel}
onTabPress={handleTabPress}
isLoggedIn={Boolean(user)}
skipAnimation={skipAnimation}
/>
) : null}
<MobileTabBar
activePanel={activePanel}
onTabPress={handleTabPress}
isLoggedIn={Boolean(user)}
hasUnseenNotifications={showUnseenDot}
hasFriendInSendouQ={hasFriendInSendouQ}
unseenFriendRequests={unseenFriendRequests}
/>
</div>
);
}
function MobileTabBar({
activePanel,
onTabPress,
isLoggedIn,
hasUnseenNotifications,
hasFriendInSendouQ,
unseenFriendRequests,
}: {
activePanel: PanelType;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
hasUnseenNotifications: boolean;
hasFriendInSendouQ: boolean;
unseenFriendRequests: number;
}) {
const { t } = useTranslation(["front", "common"]);
const chatContext = useChatContext();
return (
<nav className={styles.tabBar}>
<MobileTab
icon={<Menu />}
label={t("front:mobileNav.menu")}
isActive={activePanel === "menu"}
onPress={() => onTabPress("menu")}
/>
{isLoggedIn ? (
<>
<MobileTab
icon={<Users />}
label={t("front:mobileNav.friends")}
isActive={activePanel === "friends"}
onPress={() => onTabPress("friends")}
showNotificationDot={hasFriendInSendouQ}
badgeCount={unseenFriendRequests}
badgeLeft={hasFriendInSendouQ}
/>
<MobileTab
icon={<Calendar />}
label={t("front:sideNav.myCalendar")}
isActive={activePanel === "tourneys"}
onPress={() => onTabPress("tourneys")}
/>
<MobileTab
icon={<MessageSquare />}
label={t("front:mobileNav.chat")}
isActive={activePanel === "chat"}
onPress={() => onTabPress("chat")}
unreadCount={chatContext?.totalUnreadCount}
/>
<MobileTab
icon={<User />}
label={t("front:mobileNav.you")}
isActive={activePanel === "you"}
onPress={() => onTabPress("you")}
showNotificationDot={hasUnseenNotifications}
/>
</>
) : (
<LogInButtonContainer>
<button type="submit" className={styles.tab}>
<span className={styles.tabIcon}>
<LogIn />
</span>
<span>{t("front:mobileNav.login")}</span>
</button>
</LogInButtonContainer>
)}
</nav>
);
}
function MobileTab({
icon,
label,
isActive,
onPress,
showNotificationDot,
unreadCount,
badgeCount,
badgeLeft,
}: {
icon: React.ReactNode;
label: string;
isActive: boolean;
onPress: () => void;
showNotificationDot?: boolean;
unreadCount?: number;
badgeCount?: number;
badgeLeft?: boolean;
}) {
const count = unreadCount ?? badgeCount;
return (
<button
type="button"
className={styles.tab}
data-active={isActive}
onClick={onPress}
>
<span className={styles.tabIcon}>
{icon}
{showNotificationDot ? <NotificationDot /> : null}
{count ? (
<span
className={clsx(styles.tabBadge, {
[styles.tabBadgeLeft]: badgeLeft,
})}
>
{count}
</span>
) : null}
</span>
<span>{label}</span>
</button>
);
}
function MobilePanel({
title,
icon,
onClose,
children,
onTabPress,
isLoggedIn,
skipAnimation,
}: {
title: string;
icon: React.ReactNode;
onClose: () => void;
children: React.ReactNode;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
skipAnimation: boolean;
}) {
return (
<ModalOverlay
className={clsx(styles.panelOverlay, skipAnimation && styles.noAnimation)}
isOpen
isDismissable={false}
>
<Modal
className={clsx(styles.panel, skipAnimation && styles.noAnimation)}
>
<Dialog className={styles.panelDialog}>
<header className={styles.panelHeader}>
<div className={styles.panelIconContainer}>{icon}</div>
<h2 className={styles.panelTitle}>{title}</h2>
<button
type="button"
className={styles.panelCloseButton}
onClick={onClose}
>
<X size={18} />
</button>
</header>
<div className={clsx(styles.panelContent, "scrollbar")}>
{children}
</div>
<GhostTabBar onTabPress={onTabPress} isLoggedIn={isLoggedIn} />
</Dialog>
</Modal>
</ModalOverlay>
);
}
function MenuOverlay({
streams,
savedTournamentIds,
onClose,
onTabPress,
isLoggedIn,
skipAnimation,
}: {
streams: NonNullable<SidebarData>["streams"];
savedTournamentIds?: number[];
onClose: () => void;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
skipAnimation: boolean;
}) {
const { t } = useTranslation(["front", "common"]);
const user = useUser();
const location = useLocation();
return (
<ModalOverlay
className={clsx(styles.panelOverlay, skipAnimation && styles.noAnimation)}
isOpen
isDismissable={false}
>
<Modal
className={clsx(
styles.menuOverlay,
"scrollbar",
skipAnimation && styles.noAnimation,
)}
>
<Dialog className={styles.panelDialog}>
<header className={styles.menuHeader}>
<div className={styles.panelIconContainer}>
<Menu size={18} />
</div>
<h2 className={styles.panelTitle}>{t("front:mobileNav.menu")}</h2>
<div className={styles.menuHeaderActions}>
{!user?.roles.includes("MINOR_SUPPORT") ? (
<LinkButton
to={SUPPORT_PAGE}
size="small"
icon={<Heart />}
variant="outlined"
>
{t("common:pages.support")}
</LinkButton>
) : null}
<ShareUrlButton
variant="minimal"
shape="square"
url={`${SENDOU_INK_BASE_URL}${location.pathname}${location.search}`}
/>
<button
type="button"
className={styles.panelCloseButton}
onClick={onClose}
>
<X size={18} />
</button>
</div>
</header>
<nav aria-label={t("front:mobileNav.menu")}>
<ul className={styles.navGrid}>
{navItems
.filter(
(item) => item.name !== "trophies" || canAccessTrophies(user),
)
.map((item) => (
<li key={item.name}>
<Link
to={`/${item.url}`}
className={styles.navItem}
onClick={onClose}
>
<div className={styles.navItemImage}>
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{t(`common:pages.${item.name}` as any)}</span>
</Link>
</li>
))}
</ul>
</nav>
<section>
<header className={styles.menuHeader}>
<div className={styles.panelIconContainer}>
<Tv size={18} />
</div>
<h3 className={styles.panelTitle}>
{t("front:sideNav.streams")}
</h3>
</header>
{streams.length === 0 ? (
<div className={styles.sideNavEmpty}>
{t("front:sideNav.noStreams")}
</div>
) : null}
<ul className={styles.streamsList}>
<StreamListItems
streams={streams}
onClick={onClose}
isLoggedIn={Boolean(user)}
savedTournamentIds={savedTournamentIds}
/>
</ul>
</section>
<GhostTabBar onTabPress={onTabPress} isLoggedIn={isLoggedIn} />
</Dialog>
</Modal>
</ModalOverlay>
);
}
function FriendsPanel({
friends,
onClose,
onTabPress,
isLoggedIn,
skipAnimation,
}: {
friends: NonNullable<SidebarData>["friends"];
onClose: () => void;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
skipAnimation: boolean;
}) {
const { t } = useTranslation(["front", "common"]);
const user = useUser();
return (
<MobilePanel
title={t("front:sideNav.friends")}
icon={<Users size={18} />}
onClose={onClose}
onTabPress={onTabPress}
isLoggedIn={isLoggedIn}
skipAnimation={skipAnimation}
>
{friends.length > 0 ? (
friends.map((friend) => (
<FriendMenu key={friend.id} {...friend} onNavigate={onClose} />
))
) : (
<div className={styles.sideNavEmpty}>
{user
? t("front:sideNav.friends.noFriends")
: t("front:sideNav.friends.notLoggedIn")}
</div>
)}
<Link
to={FRIENDS_PAGE}
className={styles.panelSectionLink}
onClick={onClose}
>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</MobilePanel>
);
}
function TourneysPanel({
events,
onClose,
onTabPress,
isLoggedIn,
skipAnimation,
}: {
events: NonNullable<SidebarData>["events"];
onClose: () => void;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
skipAnimation: boolean;
}) {
const { t } = useTranslation(["front", "common"]);
return (
<MobilePanel
title={t("front:sideNav.myCalendar")}
icon={<Calendar size={18} />}
onClose={onClose}
onTabPress={onTabPress}
isLoggedIn={isLoggedIn}
skipAnimation={skipAnimation}
>
<EventsList events={events} onClick={onClose} />
<Link
to={EVENTS_PAGE}
className={styles.panelSectionLink}
onClick={onClose}
>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
</MobilePanel>
);
}
function YouPanel({
onClose,
onTabPress,
isLoggedIn,
skipAnimation,
}: {
onClose: () => void;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
skipAnimation: boolean;
}) {
const { t } = useTranslation(["front", "common"]);
const user = useUser();
const { notifications, unseenIds } = useNotifications();
if (!user) {
return null;
}
return (
<MobilePanel
title={t("front:mobileNav.you")}
icon={<User size={18} />}
onClose={onClose}
onTabPress={onTabPress}
isLoggedIn={isLoggedIn}
skipAnimation={skipAnimation}
>
<div className={styles.youPanelUserRow}>
<Link
to={userPage(user)}
className={styles.youPanelUser}
onClick={onClose}
>
<Avatar user={user} size="sm" />
<span className={styles.youPanelUsername}>{user.username}</span>
</Link>
<Link
to={SETTINGS_PAGE}
className={styles.youPanelSettingsButton}
onClick={onClose}
aria-label={t("common:pages.settings")}
>
<Settings size={18} />
</Link>
</div>
{notifications ? (
<NotificationContent
notifications={notifications}
unseenIds={unseenIds}
onClose={onClose}
/>
) : null}
</MobilePanel>
);
}
function ChatPanel({
onClose,
onTabPress,
isLoggedIn,
skipAnimation,
}: {
onClose: () => void;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
skipAnimation: boolean;
}) {
return (
<ModalOverlay
className={clsx(styles.panelOverlay, skipAnimation && styles.noAnimation)}
isOpen
isDismissable={false}
>
<Modal
className={clsx(
styles.menuOverlay,
skipAnimation && styles.noAnimation,
)}
>
<Dialog className={styles.panelDialog}>
<ChatSidebar onClose={onClose} />
<GhostTabBar onTabPress={onTabPress} isLoggedIn={isLoggedIn} />
</Dialog>
</Modal>
</ModalOverlay>
);
}
const LOGGED_IN_TABS: PanelType[] = [
"menu",
"friends",
"tourneys",
"chat",
"you",
];
const LOGGED_OUT_TABS: PanelType[] = ["menu"];
function GhostTabBar({
onTabPress,
isLoggedIn,
}: {
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
}) {
const tabs = isLoggedIn ? LOGGED_IN_TABS : LOGGED_OUT_TABS;
return (
<div className={styles.ghostTabBar} aria-hidden="true">
{tabs.map((tab) => (
<button
key={tab}
type="button"
className={styles.ghostTab}
tabIndex={-1}
onClick={() => onTabPress(tab)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,73 @@
.wrapper {
position: relative;
display: inline-flex;
flex-shrink: 0;
width: fit-content;
}
.clickable {
cursor: pointer;
border: none;
padding: 0;
background: none;
}
.badge {
position: absolute;
bottom: 15%;
inset-inline-start: 15%;
transform: translate(-50%, 50%);
display: grid;
place-items: center;
border-radius: var(--radius-full);
border: 2px solid var(--color-bg);
color: var(--color-bg);
& > svg {
stroke-width: 3;
}
}
.badgeMd {
width: 1.25rem;
height: 1.25rem;
& > svg {
width: 0.75rem;
height: 0.75rem;
}
}
.badgeSm {
width: 0.9rem;
height: 0.9rem;
border-width: 1.5px;
& > svg {
width: 0.6rem;
height: 0.6rem;
}
}
.badgeXs {
width: 0.65rem;
height: 0.65rem;
border-width: 1.5px;
& > svg {
width: 0.45rem;
height: 0.45rem;
}
}
.positive {
background-color: var(--color-success);
}
.negative {
background-color: var(--color-error);
}
.neutral {
background-color: var(--color-text-high);
}

View File

@@ -0,0 +1,75 @@
import clsx from "clsx";
import { Check, Minus, X } from "lucide-react";
import type * as React from "react";
import type { Tables } from "~/db/tables";
import styles from "./NoteAvatar.module.css";
type Sentiment = Tables["PrivateUserNote"]["sentiment"];
const BADGE_CLASS: Record<Sentiment, string> = {
POSITIVE: styles.positive,
NEUTRAL: styles.neutral,
NEGATIVE: styles.negative,
};
const BADGE_ICON: Record<Sentiment, React.ReactNode> = {
POSITIVE: <Check />,
NEUTRAL: <Minus />,
NEGATIVE: <X />,
};
const SIZE_CLASS = {
xs: styles.badgeXs,
sm: styles.badgeSm,
md: styles.badgeMd,
} as const;
/**
* Wraps an avatar (or any node) and overlays a sentiment badge on the bottom-left corner when
* `sentiment` is set: POSITIVE → green check, NEGATIVE → red cross, NEUTRAL → grey dash. Renders the
* children without a badge when `sentiment` is `null`/`undefined`. `size` scales the badge to match
* the wrapped avatar (`xs` for tiny avatars, `sm` for small avatars, `md` for large ones).
*
* `onClick` makes the whole wrapper (avatar and badge) clickable. It is kept out of the tab order, so
* only use it as a shortcut to an action that is also available elsewhere.
*/
export function NoteAvatar({
sentiment,
size = "md",
className,
onClick,
children,
}: {
sentiment?: Sentiment | null;
size?: keyof typeof SIZE_CLASS;
className?: string;
onClick?: () => void;
children: React.ReactNode;
}) {
const Wrapper = onClick ? "button" : "div";
return (
<Wrapper
type={onClick ? "button" : undefined}
className={clsx(styles.wrapper, className, {
[styles.clickable]: onClick,
})}
onClick={onClick}
tabIndex={onClick ? -1 : undefined}
>
{children}
{sentiment ? (
<span
className={clsx(
styles.badge,
SIZE_CLASS[size],
BADGE_CLASS[sentiment],
)}
aria-hidden
>
{BADGE_ICON[sentiment]}
</span>
) : null}
</Wrapper>
);
}

View File

@@ -0,0 +1,42 @@
.dotWrapper {
position: absolute;
top: var(--dot-top, -2px);
right: var(--dot-right, -2px);
width: 8px;
height: 8px;
pointer-events: none;
}
.dot {
display: block;
width: 100%;
height: 100%;
position: absolute;
background-color: var(--color-text-accent);
border-radius: 100%;
outline: 2px solid var(--color-bg);
}
.pulse {
display: block;
width: calc(100% + 20px);
height: calc(100% + 20px);
position: absolute;
top: -10px;
left: -10px;
border-radius: 100%;
background-color: var(--color-text-accent);
animation: pulse 2s infinite;
opacity: 0;
}
@keyframes pulse {
from {
transform: scale(0);
opacity: 1;
}
70% {
transform: scale(1);
opacity: 0;
}
}

View File

@@ -0,0 +1,17 @@
import clsx from "clsx";
import styles from "./NotificationDot.module.css";
export function NotificationDot({
className,
testId,
}: {
className?: string;
testId?: string;
}) {
return (
<span className={clsx(styles.dotWrapper, className)} data-testid={testId}>
<span className={styles.pulse} />
<span className={styles.dot} />
</span>
);
}

View File

@@ -0,0 +1,6 @@
.container {
height: 300px;
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-2-5) var(--s-3);
}

View File

@@ -0,0 +1,290 @@
/**
* Line chart of a game's objective-counter reads: one line per team
* (remaining count over match time, so lines fall toward 0). Control is a
* state rather than a count, so it gets its own lane in a gutter below the
* zero gridline instead of sharing the count axis — a strip in the
* controlling team's color, absent while neither team controls. The
* zero gridline is drawn in the stronger border color to read as the
* divider between the counts above and the lane below. Penalty is a
* translucent band filled between score and score + penalty — its thickness
* is the extra count the team must burn through before its score moves
* again, so it grows when a penalty lands and shrinks as it counts down.
* Control state and exact values stay in the shared hover tooltip.
*
* Series colors are the chart tokens from vars.css — the theme's text-tier
* colors are too pastel to tell apart as marks; these are the same two hues
* re-stepped per theme and validated for CVD separation and surface
* contrast.
*/
import {
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
Tooltip,
} from "chart.js";
import { Line } from "react-chartjs-2";
import { useTranslation } from "react-i18next";
import { useThemeColors } from "~/hooks/useThemeColors";
import styles from "./ObjectiveTimeline.module.css";
import {
formatElapsed,
smoothPenalties,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
ChartJS.register(
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
);
/** count-axis units of gutter kept below zero for the control lane */
const CONTROL_LANE_DEPTH = 13;
const CONTROL_LANE_Y = -6;
const CONTROL_LANE_WIDTH = 6;
const COUNT_TICK_STEP = 25;
/** One objective-counter read, values in `[alpha, bravo]` order. */
export interface ObjectiveTimelineSample {
/** seconds shown on the match timer at the read ("3:35" = 215); null = unreadable */
time: number | null;
/** displayed count per team; null = unreadable */
score: [number | null, number | null];
/** penalty pill value per team; null = no pill (or unreadable) */
penalty: [number | null, number | null];
/** which team held the objective at the read */
control: [boolean, boolean];
}
export interface ObjectiveTimelineEvent {
/** whole seconds into the source (video, stream or game) the read was made at */
t: number;
data: ObjectiveTimelineSample;
}
export function ObjectiveTimeline({
events,
teamLabels,
domain,
showTooltip = true,
}: {
events: readonly ObjectiveTimelineEvent[];
teamLabels: readonly [string, string];
/** x-axis range override, to share the player-status timeline's axis */
domain?: [number, number];
/** off when a parent renders its own scrub readout over the chart */
showTooltip?: boolean;
}) {
const { t } = useTranslation(["common"]);
const colors = useThemeColors({
alpha: "--color-chart-alpha",
bravo: "--color-chart-bravo",
border: "--color-border",
borderHigh: "--color-border-high",
text: "--color-text-high",
});
const sorted = events.toSorted((a, b) => a.t - b.t);
if (sorted.length === 0) return null;
const teamColors = [colors.alpha, colors.bravo];
const scoreDatasets = ([0, 1] as const).map((side) => ({
label: teamLabels[side],
data: sorted.map((event) => ({
x: event.t,
y: event.data.score[side],
})),
borderColor: teamColors[side],
backgroundColor: teamColors[side],
pointBackgroundColor: teamColors[side],
pointBorderColor: teamColors[side],
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
hitRadius: 20,
spanGaps: true,
cubicInterpolationMode: "monotone" as const,
}));
// strip along the lane while the team is in control; the losing edge is
// kept in the lane too so the strip extends exactly to where control ended
const controlDatasets = ([0, 1] as const).map((side) => ({
label: `${teamLabels[side]} control`,
data: sorted.map((event, i) => ({
x: event.t,
y:
event.data.control[side] || sorted[i - 1]?.data.control[side]
? CONTROL_LANE_Y
: null,
})),
borderColor: teamColors[side],
borderWidth: CONTROL_LANE_WIDTH,
borderCapStyle: "round" as const,
pointRadius: 0,
pointHoverRadius: 0,
hitRadius: 0,
spanGaps: false,
stepped: "after" as const,
}));
// band between score and score + penalty; its thickness is the penalty
const penaltyDatasets = ([0, 1] as const).map((side) => {
const penalties = smoothPenalties(
sorted.map((event) => ({
t: event.t,
penalty: event.data.penalty[side],
})),
);
let lastScore: number | null = null;
return {
label: `${teamLabels[side]} penalty`,
data: sorted.map((event, i) => {
lastScore = event.data.score[side] ?? lastScore;
return {
x: event.t,
y: lastScore === null ? null : lastScore + (penalties[i] ?? 0),
};
}),
borderColor: `${teamColors[side]}8c`,
backgroundColor: `${teamColors[side]}38`,
borderWidth: 1,
pointRadius: 0,
pointHoverRadius: 0,
cubicInterpolationMode: "monotone" as const,
fill: { target: side },
// edge only where a penalty exists so zero-height bands stay invisible
segment: {
borderColor: (ctx: { p0DataIndex: number; p1DataIndex: number }) =>
(penalties[ctx.p0DataIndex] ?? 0) > 0 ||
(penalties[ctx.p1DataIndex] ?? 0) > 0
? undefined
: "transparent",
},
};
});
const datasets = [...scoreDatasets, ...penaltyDatasets, ...controlDatasets];
return (
<div className={styles.container}>
<Line
data={{ datasets }}
options={{
animation: false,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
layout: { autoPadding: false },
scales: {
x: {
type: "linear",
min: domain?.[0] ?? sorted[0]!.t,
max: domain?.[1] ?? sorted[sorted.length - 1]!.t,
grid: { color: colors.border },
border: { color: colors.borderHigh },
ticks: {
color: colors.text,
maxRotation: 0,
maxTicksLimit: 8,
align: "inner",
callback: (value) => formatElapsed(Number(value)),
},
},
y: {
min: -CONTROL_LANE_DEPTH,
suggestedMax: 100,
bounds: "data",
grid: {
color: (ctx) => gridColor(ctx.tick?.value ?? 0, colors),
tickColor: (ctx) => gridColor(ctx.tick?.value ?? 0, colors),
},
border: { color: colors.borderHigh },
afterBuildTicks: (axis) => {
axis.ticks = countAxisTicks(axis.max);
},
afterFit: (axis) => {
axis.width = TIMELINE_PLOT_GUTTER_PX;
},
ticks: { color: colors.text, autoSkip: false },
},
},
plugins: {
legend: {
labels: {
color: colors.text,
boxWidth: 10,
boxHeight: 10,
filter: (item) => (item.datasetIndex ?? 0) < 2,
},
},
tooltip: {
enabled: showTooltip,
filter: (item) => item.datasetIndex < 2,
callbacks: {
title: (items) => {
if (!items[0]) return "";
const clock = sorted[items[0].dataIndex]?.data.time;
const elapsed = formatElapsed(items[0].parsed.x ?? 0);
return clock != null
? `${elapsed} · ${t("common:objectiveTimeline.timeLeft", {
time: formatClock(clock),
})}`
: elapsed;
},
label: (item) => {
const event = sorted[item.dataIndex];
if (!event) return "";
const side = item.datasetIndex as 0 | 1;
const { score, penalty, control } = event.data;
return [
`${teamLabels[side]}: ${score[side] ?? "?"}`,
penalty[side] !== null
? t("common:objectiveTimeline.penalty", {
value: penalty[side],
})
: null,
control[side]
? t("common:objectiveTimeline.inControl")
: null,
]
.filter(Boolean)
.join(" · ");
},
},
},
},
}}
/>
</div>
);
}
/**
* One tick every 25 up to the top of the data and none below zero, so the
* control gutter stays free of axis furniture.
*/
function countAxisTicks(max: number) {
const ticks = [];
for (let value = 0; value <= max; value += COUNT_TICK_STEP) {
ticks.push({ value });
}
return ticks;
}
/** zero divides counts from the lane, so it is drawn stronger; the gutter has no grid */
function gridColor(
value: number,
colors: { border: string; borderHigh: string },
) {
if (value < 0) return "transparent";
return value === 0 ? colors.borderHigh : colors.border;
}
/** the match timer's M:SS (215 → "3:35") */
function formatClock(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const rest = String(Math.floor(seconds % 60)).padStart(2, "0");
return `${minutes}:${rest}`;
}

View File

@@ -0,0 +1,117 @@
.container {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-1);
background-color: var(--color-bg-high);
padding: var(--s-2) var(--s-3);
border-radius: var(--radius-full);
width: fit-content;
margin-inline: auto;
}
.arrow {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-full);
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.7;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
.page {
display: flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 var(--s-1);
border: none;
background: transparent;
color: var(--color-text);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
cursor: pointer;
border-radius: var(--radius-full);
transition: background-color 0.15s ease;
&:hover:not(.pageActive) {
background-color: var(--color-bg-higher);
}
}
.pageActive {
background-color: var(--color-text);
color: var(--color-text-inverse);
}
.ellipsis {
display: flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 2rem;
color: var(--color-text);
font-size: var(--font-sm);
}
.ellipsisButton {
border: none;
background: transparent;
cursor: pointer;
border-radius: var(--radius-full);
transition: background-color 0.15s ease;
&:hover {
background-color: var(--color-bg-higher);
}
}
.jumpToForm {
display: flex;
align-items: center;
justify-content: center;
}
.jumpToInput {
max-width: 2.5rem;
max-height: 1.75rem;
padding: 0 var(--s-1);
border: 2px solid var(--color-primary);
border-radius: var(--radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
text-align: center;
&:focus {
outline: none;
}
}
@container (width < 580px) {
.desktopOnly {
display: none;
}
}
@container (width >= 580px) {
.mobileOnly {
display: none;
}
}

View File

@@ -0,0 +1,140 @@
import { describe, expect, test } from "vitest";
import { getPageNumbers } from "./Pagination";
/** What is rendered on a narrow (mobile) viewport — `desktopOnly` items are hidden. */
function mobileView(currentPage: number, pagesCount: number) {
return getPageNumbers(currentPage, pagesCount)
.filter((item) => !item.desktopOnly)
.map((item) => item.value);
}
/** What is rendered on a wide (desktop) viewport — `mobileOnly` items are hidden. */
function desktopView(currentPage: number, pagesCount: number) {
return getPageNumbers(currentPage, pagesCount)
.filter((item) => !item.mobileOnly)
.map((item) => item.value);
}
describe("getPageNumbers", () => {
test("shows every page without ellipsis when there are 5 or fewer", () => {
expect(mobileView(2, 5)).toEqual([1, 2, 3, 4, 5]);
expect(desktopView(2, 5)).toEqual([1, 2, 3, 4, 5]);
});
test("shows every page on desktop when there are 9 or fewer", () => {
expect(desktopView(2, 9)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(desktopView(5, 9)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test("inserts a mobile ellipsis instead of silently hiding middle pages", () => {
// Regression: page=2 of 9 used to render "1 2 8 9" with no ellipsis
expect(mobileView(2, 9)).toEqual([1, 2, 3, "...", 9]);
});
test("shows a bridging number on the first page instead of a lonely jump", () => {
// Regression: page=1 of 8 used to render "1 2 ... 8" with nothing in between
expect(mobileView(1, 8)).toEqual([1, 2, 3, "...", 8]);
expect(mobileView(1, 20)).toEqual([1, 2, 3, "...", 20]);
expect(desktopView(1, 20)).toEqual([1, 2, 3, 4, "...", 20]);
});
test("shows a bridging number on the last page instead of a lonely jump", () => {
expect(mobileView(8, 8)).toEqual([1, "...", 6, 7, 8]);
expect(mobileView(20, 20)).toEqual([1, "...", 18, 19, 20]);
expect(desktopView(20, 20)).toEqual([1, "...", 17, 18, 19, 20]);
});
test("keeps the current page and its neighbours visible on mobile", () => {
const view = mobileView(5, 9);
expect(view).toContain(4);
expect(view).toContain(5);
expect(view).toContain(6);
// no confusing jump straight from 1 to the middle
expect(view).toEqual([1, "...", 4, 5, 6, "...", 9]);
});
test("always keeps the first and last page", () => {
for (const currentPage of [1, 7, 20]) {
const view = mobileView(currentPage, 20);
expect(view[0]).toBe(1);
expect(view.at(-1)).toBe(20);
}
});
test("windows around the current page with ellipses on both sides for many pages", () => {
expect(mobileView(10, 20)).toEqual([1, "...", 9, 10, 11, "...", 20]);
expect(desktopView(10, 20)).toEqual([
1,
"...",
8,
9,
10,
11,
12,
"...",
20,
]);
});
test("omits the leading ellipsis when the current page is near the start", () => {
expect(desktopView(2, 20)).toEqual([1, 2, 3, 4, "...", 20]);
expect(mobileView(2, 20)).toEqual([1, 2, 3, "...", 20]);
});
test("omits the trailing ellipsis when the current page is near the end", () => {
expect(desktopView(19, 20)).toEqual([1, "...", 17, 18, 19, 20]);
expect(mobileView(19, 20)).toEqual([1, "...", 18, 19, 20]);
});
test("shows a bridging number instead of an ellipsis that hides a single page", () => {
// An ellipsis takes the same space as one page number, so replacing a
// lone hidden page with "..." is never an improvement (same intent as the
// edge "lonely jump" fix, but for windows one step inward).
// desktop window around page 5 of 10 leaves only page 2 hidden on the left
expect(desktopView(5, 10)).toEqual([1, 2, 3, 4, 5, 6, 7, "...", 10]);
// ...and only page 9 hidden on the right for page 6 of 10
expect(desktopView(6, 10)).toEqual([1, "...", 4, 5, 6, 7, 8, 9, 10]);
// mobile window around page 3 of 6 leaves only page 5 hidden
expect(mobileView(3, 6)).toEqual([1, 2, 3, 4, 5, 6]);
});
test("never renders an ellipsis in place of a single hidden page", () => {
for (let pagesCount = 1; pagesCount <= 25; pagesCount++) {
for (let currentPage = 1; currentPage <= pagesCount; currentPage++) {
for (const view of [
mobileView(currentPage, pagesCount),
desktopView(currentPage, pagesCount),
]) {
for (let i = 1; i < view.length - 1; i++) {
if (view[i] !== "...") continue;
const prev = view[i - 1];
const next = view[i + 1];
if (typeof prev === "number" && typeof next === "number") {
expect(next - prev).toBeGreaterThan(2);
}
}
}
}
}
});
test("never produces duplicate page numbers", () => {
for (let pagesCount = 1; pagesCount <= 25; pagesCount++) {
for (let currentPage = 1; currentPage <= pagesCount; currentPage++) {
const values = getPageNumbers(currentPage, pagesCount)
.map((item) => item.value)
.filter((value): value is number => value !== "...");
expect(new Set(values).size).toBe(values.length);
}
}
});
test("always includes the current page in both views", () => {
for (let pagesCount = 1; pagesCount <= 25; pagesCount++) {
for (let currentPage = 1; currentPage <= pagesCount; currentPage++) {
expect(mobileView(currentPage, pagesCount)).toContain(currentPage);
expect(desktopView(currentPage, pagesCount)).toContain(currentPage);
}
}
});
});

View File

@@ -0,0 +1,277 @@
import clsx from "clsx";
import { ChevronLeft, ChevronRight } from "lucide-react";
import * as React from "react";
import styles from "./Pagination.module.css";
export function Pagination({
currentPage,
pagesCount,
nextPage,
previousPage,
setPage,
compact,
}: {
currentPage: number;
pagesCount: number;
nextPage: () => void;
previousPage: () => void;
setPage: (page: number) => void;
/** Renders only the current page number with prev/next arrows */
compact?: boolean;
}) {
const pages = getPageNumbers(currentPage, pagesCount);
const [jumpToIndex, setJumpToIndex] = React.useState<number | null>(null);
return (
<div className={styles.container}>
<button
type="button"
className={styles.arrow}
disabled={currentPage === 1}
onClick={previousPage}
aria-label="Previous page"
>
<ChevronLeft size={18} />
</button>
{compact ? (
<span className={clsx(styles.page, styles.pageActive)}>
{currentPage}
</span>
) : (
pages.map((page, index) =>
page.value === "..." ? (
<JumpToEllipsis
key={`ellipsis-${index}`}
isOpen={jumpToIndex === index}
pagesCount={pagesCount}
desktopOnly={page.desktopOnly}
mobileOnly={page.mobileOnly}
onOpen={() => setJumpToIndex(index)}
onClose={() => setJumpToIndex(null)}
onJump={(page) => {
setPage(page);
setJumpToIndex(null);
}}
/>
) : (
<PageButton
key={page.value}
page={page.value}
currentPage={currentPage}
desktopOnly={page.desktopOnly}
setPage={setPage}
/>
),
)
)}
<button
type="button"
className={styles.arrow}
disabled={currentPage === pagesCount}
onClick={nextPage}
aria-label="Next page"
>
<ChevronRight size={18} />
</button>
</div>
);
}
function JumpToEllipsis({
isOpen,
pagesCount,
desktopOnly,
mobileOnly,
onOpen,
onClose,
onJump,
}: {
isOpen: boolean;
pagesCount: number;
desktopOnly?: boolean;
mobileOnly?: boolean;
onOpen: () => void;
onClose: () => void;
onJump: (page: number) => void;
}) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const value = formData.get("page") as string;
if (!value) {
onClose();
return;
}
const pageNumber = Number(value);
if (Number.isNaN(pageNumber) || pageNumber < 1 || pageNumber > pagesCount) {
onClose();
return;
}
onJump(pageNumber);
};
const handleBlur = () => {
onClose();
};
if (isOpen) {
return (
<form
onSubmit={handleSubmit}
className={clsx(styles.jumpToForm, {
[styles.desktopOnly]: desktopOnly,
[styles.mobileOnly]: mobileOnly,
})}
>
<input
// biome-ignore lint/a11y/noAutofocus: valid use case
autoFocus
name="page"
type="text"
inputMode="numeric"
pattern="[0-9]*"
className={styles.jumpToInput}
onBlur={handleBlur}
aria-label={`Jump to page (1-${pagesCount})`}
/>
</form>
);
}
return (
<button
type="button"
className={clsx(styles.ellipsis, styles.ellipsisButton, {
[styles.desktopOnly]: desktopOnly,
[styles.mobileOnly]: mobileOnly,
})}
onClick={onOpen}
aria-label="Jump to page"
>
...
</button>
);
}
function PageButton({
page,
currentPage,
desktopOnly,
setPage,
}: {
page: number;
currentPage: number;
desktopOnly?: boolean;
setPage: (page: number) => void;
}) {
return (
<button
type="button"
className={clsx(styles.page, {
[styles.pageActive]: page === currentPage,
[styles.desktopOnly]: desktopOnly,
})}
onClick={() => setPage(page)}
aria-current={page === currentPage ? "page" : undefined}
>
{page}
</button>
);
}
type PageItem = {
value: number | "...";
desktopOnly?: boolean;
mobileOnly?: boolean;
};
export function getPageNumbers(
currentPage: number,
pagesCount: number,
): PageItem[] {
if (pagesCount <= 5) {
return Array.from({ length: pagesCount }, (_, i) => ({ value: i + 1 }));
}
const showAllOnDesktop = pagesCount <= 9;
const { start: mobileStart, end: mobileEnd } = innerPageWindow(
currentPage,
pagesCount,
1,
);
const { start: desktopStart, end: desktopEnd } = showAllOnDesktop
? { start: 2, end: pagesCount - 1 }
: innerPageWindow(currentPage, pagesCount, 2);
const isMobileVisible = (page: number) =>
page >= mobileStart && page <= mobileEnd;
const isDesktopVisible = (page: number) =>
page >= desktopStart && page <= desktopEnd;
const pages: PageItem[] = [];
pages.push({ value: 1 });
const needsDesktopEllipsisStart = desktopStart > 2;
const needsMobileEllipsisStart = mobileStart > 2;
if (needsDesktopEllipsisStart && needsMobileEllipsisStart) {
pages.push({ value: "..." });
} else if (needsMobileEllipsisStart) {
pages.push({ value: "...", mobileOnly: true });
} else if (needsDesktopEllipsisStart) {
pages.push({ value: "...", desktopOnly: true });
}
for (let i = 2; i <= pagesCount - 1; i++) {
if (isDesktopVisible(i)) {
pages.push({ value: i, desktopOnly: !isMobileVisible(i) });
}
}
const needsDesktopEllipsisEnd = desktopEnd < pagesCount - 1;
const needsMobileEllipsisEnd = mobileEnd < pagesCount - 1;
if (needsDesktopEllipsisEnd && needsMobileEllipsisEnd) {
pages.push({ value: "..." });
} else if (needsMobileEllipsisEnd) {
pages.push({ value: "...", mobileOnly: true });
} else if (needsDesktopEllipsisEnd) {
pages.push({ value: "...", desktopOnly: true });
}
pages.push({ value: pagesCount });
return pages;
}
/**
* Inclusive range of inner page numbers (excluding the always-shown first and
* last page) to render around the current page. The window is nudged inward by
* one when the current page is the very first or last page, so the edge view
* shows a bridging number instead of a lonely jump like "1 2 … 8".
*
* When exactly one page would be left between the window and the always-shown
* first or last page, the window is widened to include it: an ellipsis takes
* the same space as a single page number, so "1 … 3" is never better than
* "1 2 3".
*/
function innerPageWindow(
currentPage: number,
pagesCount: number,
radius: number,
): { start: number; end: number } {
const startNudge = currentPage === pagesCount ? 1 : 0;
const endNudge = currentPage === 1 ? 1 : 0;
let start = Math.max(2, currentPage - radius - startNudge);
let end = Math.min(pagesCount - 1, currentPage + radius + endNudge);
if (start === 3) start = 2;
if (end === pagesCount - 2) end = pagesCount - 1;
return { start, end };
}

View File

@@ -0,0 +1,3 @@
.placeholder {
height: 100vh;
}

View File

@@ -0,0 +1,6 @@
import styles from "./Placeholder.module.css";
/** Renders a blank placeholder component that can be used while content is loading. Better than returning null because it keeps the footer down where it belongs. */
export function Placeholder() {
return <div className={styles.placeholder} />;
}

View File

@@ -0,0 +1,75 @@
import { useTranslation } from "react-i18next";
import { ordinalSuffix } from "~/utils/i18n";
import {
FIRST_PLACEMENT_ICON_PATH,
SECOND_PLACEMENT_ICON_PATH,
THIRD_PLACEMENT_ICON_PATH,
} from "~/utils/urls";
export type PlacementProps = {
placement: number;
iconClassName?: string;
textClassName?: string;
size?: number;
textOnly?: boolean;
/** Render plain text, no icon or wrapping html elements */
plain?: boolean;
showAsSuperscript?: boolean;
};
const getSpecialPlacementIconPath = (placement: number): string | null => {
switch (placement) {
case 3:
return THIRD_PLACEMENT_ICON_PATH;
case 2:
return SECOND_PLACEMENT_ICON_PATH;
case 1:
return FIRST_PLACEMENT_ICON_PATH;
default:
return null;
}
};
export function Placement({
placement,
iconClassName,
textClassName,
size = 20,
textOnly = false,
showAsSuperscript = true,
plain = false,
}: PlacementProps) {
const { i18n } = useTranslation();
const suffix = ordinalSuffix(placement, i18n.language);
const isSuperscript = showAsSuperscript && suffix.startsWith("^");
const ordinalSuffixText = suffix.replace(/^\^/, "");
const iconPath = textOnly ? null : getSpecialPlacementIconPath(placement);
if (plain) {
return `${placement}${ordinalSuffixText}`;
}
if (!iconPath) {
return (
<span className={textClassName}>
{placement}
{isSuperscript ? <sup>{ordinalSuffixText}</sup> : ordinalSuffixText}
</span>
);
}
const placementString = `${placement}${ordinalSuffixText}`;
return (
<img
alt={placementString}
title={placementString}
src={iconPath}
className={iconClassName}
height={size}
width={size}
/>
);
}

View File

@@ -0,0 +1,94 @@
.container {
display: flex;
flex-direction: column;
gap: var(--s-2);
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-2-5) var(--s-3);
}
.legend {
display: flex;
gap: var(--s-4);
justify-content: center;
font-size: var(--font-xs);
color: var(--color-text-high);
}
.legendItem {
display: flex;
align-items: center;
gap: var(--s-1);
}
.legendSwatchDead,
.legendSwatchSpecial {
width: 10px;
height: 10px;
border-radius: 2px;
}
.legendSwatchDead {
background-color: var(--color-error);
}
.legendSwatchSpecial {
background-color: var(--color-info);
}
.team {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.teamLabel {
font-size: var(--font-xs);
font-weight: 600;
color: var(--color-text-high);
}
.row {
display: flex;
align-items: center;
}
.slotLabel {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
width: var(--plot-gutter, 36px);
padding-right: var(--s-2);
}
.track {
position: relative;
flex: 1;
height: 14px;
overflow: hidden;
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
}
.spanDead,
.spanSpecial {
position: absolute;
top: 2px;
bottom: 2px;
min-width: 3px;
border-radius: 2px;
}
.spanDead {
background-color: var(--color-error);
opacity: 0.75;
}
.spanSpecial {
background-color: var(--color-info);
opacity: 0.75;
}
.unknownWeapon {
opacity: 0.5;
}

View File

@@ -0,0 +1,182 @@
/**
* Per-player status bands over a game's scanned icon-strip reads: one row
* per player (weapon icon as the label), a band while the player was
* splatted and another while they held their special, both teams stacked.
* Rendered above the ObjectiveTimeline chart on the same `t` seconds axis —
* pass `domain` so both span the same range. Reads re-confirm an unchanged
* state every few seconds; a longer sample gap means the HUD was not
* observed, so bands never bridge across one (the state there is unknown,
* not continued).
*/
import { useTranslation } from "react-i18next";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import { Image, WeaponImage } from "./Image";
import {
formatElapsed,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
import styles from "./PlayerStatusTimeline.module.css";
/** Consecutive reads further apart than this leave an unknown gap. */
const MAX_BRIDGE_SECONDS = 15;
/** Trailing open band drawn this long past its last confirming read. */
export const PLAYER_STATUS_TAIL_SECONDS = 1;
type PlayerFlags = readonly [boolean, boolean, boolean, boolean];
/** One icon-strip read, sides in `[alpha, bravo]` order. */
export interface PlayerStatusTimelineSample {
/** seconds into the source (video, stream or game) the read was made at */
t: number;
special: readonly [PlayerFlags, PlayerFlags];
dead: readonly [PlayerFlags, PlayerFlags];
}
export interface PlayerStatusTimelineTeam {
label: string;
/** weapon per slot in row order; null/absent slots render a placeholder */
weapons: (MainWeaponId | null)[];
}
export function PlayerStatusTimeline({
samples,
teams,
domain,
}: {
samples: readonly PlayerStatusTimelineSample[];
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
/** x-axis range override, to share the objective chart's axis */
domain?: [number, number];
}) {
const { t } = useTranslation(["common"]);
const sorted = samples.toSorted((a, b) => a.t - b.t);
if (sorted.length === 0) return null;
const min = Math.min(domain?.[0] ?? Number.POSITIVE_INFINITY, sorted[0]!.t);
const max = Math.max(
domain?.[1] ?? 0,
sorted[sorted.length - 1]!.t + PLAYER_STATUS_TAIL_SECONDS,
);
const range = Math.max(1, max - min);
const leftOf = (span: StatusSpan) => `${((span.start - min) / range) * 100}%`;
const widthOf = (span: StatusSpan) =>
`${((span.end - span.start) / range) * 100}%`;
const titleOf = (label: string, span: StatusSpan) =>
`${label} · ${formatElapsed(span.start)}–${formatElapsed(span.end)}`;
return (
<div
className={styles.container}
style={
{
"--plot-gutter": `${TIMELINE_PLOT_GUTTER_PX}px`,
} as React.CSSProperties
}
>
<div className={styles.legend}>
<span className={styles.legendItem}>
<span className={styles.legendSwatchDead} />
{t("common:playerStatusTimeline.splatted")}
</span>
<span className={styles.legendItem}>
<span className={styles.legendSwatchSpecial} />
{t("common:playerStatusTimeline.specialReady")}
</span>
</div>
{([0, 1] as const).map((side) => (
<div key={side} className={styles.team}>
<div className={styles.teamLabel}>{teams[side].label}</div>
{[0, 1, 2, 3].map((slot) => (
<div key={slot} className={styles.row}>
<div className={styles.slotLabel}>
<SlotWeapon weaponSplId={teams[side].weapons[slot] ?? null} />
</div>
<div className={styles.track}>
{statusSpans(sorted, (sample) => sample.dead[side][slot]!).map(
(span, i) => (
<div
key={`d${i}`}
className={styles.spanDead}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.splatted"),
span,
)}
/>
),
)}
{statusSpans(
sorted,
(sample) => sample.special[side][slot]!,
).map((span, i) => (
<div
key={`s${i}`}
className={styles.spanSpecial}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.specialReady"),
span,
)}
/>
))}
</div>
</div>
))}
</div>
))}
</div>
);
}
function SlotWeapon({ weaponSplId }: { weaponSplId: MainWeaponId | null }) {
if (weaponSplId === null) {
return (
<Image
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={22}
className={styles.unknownWeapon}
/>
);
}
return <WeaponImage weaponSplId={weaponSplId} variant="badge" size={22} />;
}
interface StatusSpan {
start: number;
end: number;
}
/**
* Contiguous stretches where the flag held true: a span opens at its first
* true read and closes at the read that shows false — or one second past
* its last confirmation when the next read is too far away (or the series
* ends) to know what happened in between.
*/
export function statusSpans(
sorted: readonly PlayerStatusTimelineSample[],
flagOf: (sample: PlayerStatusTimelineSample) => boolean,
): StatusSpan[] {
const spans: StatusSpan[] = [];
let start: number | null = null;
let lastTrueT = 0;
for (const sample of sorted) {
const flag = flagOf(sample);
if (start !== null && sample.t - lastTrueT > MAX_BRIDGE_SECONDS) {
spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS });
start = null;
}
if (flag) {
start ??= sample.t;
lastTrueT = sample.t;
} else if (start !== null) {
spans.push({ start, end: sample.t });
start = null;
}
}
if (start !== null)
spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS });
return spans;
}

View File

@@ -0,0 +1,12 @@
import * as React from "react";
import { useNavigate } from "react-router";
export function Redirect({ to }: { to: string }) {
const navigate = useNavigate();
React.useEffect(() => {
navigate(to);
}, [navigate, to]);
return null;
}

View File

@@ -0,0 +1,48 @@
import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, test } from "vitest";
import { render } from "vitest-browser-react";
import { RelativeTime } from "./RelativeTime";
const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
timeZoneName: "short",
};
function renderRelativeTime(timestamp: number) {
const router = createMemoryRouter(
[
{
path: "/",
element: <RelativeTime timestamp={timestamp}>3 days ago</RelativeTime>,
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
function expectedTitle(timestamp: number) {
const language =
navigator.languages.find(
(lang) => lang.split("-")[0].toLowerCase() === "en",
) ?? "en";
return new Intl.DateTimeFormat(language, FORMAT_OPTIONS).format(timestamp);
}
describe("RelativeTime", () => {
test("tooltip shows the date the millisecond timestamp points to", async () => {
const timestamp = new Date("2025-08-02T12:00:00Z").getTime();
const screen = await renderRelativeTime(timestamp);
const abbr = screen.getByText("3 days ago");
await expect.element(abbr).toBeVisible();
expect(abbr.element().getAttribute("title")).toBe(expectedTitle(timestamp));
});
});

View File

@@ -0,0 +1,24 @@
import type * as React from "react";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
export function RelativeTime({
children,
timestamp,
}: {
children: React.ReactNode;
timestamp: number;
}) {
const { formatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
day: "numeric",
month: "numeric",
timeZoneName: "short",
});
return (
<abbr title={formatter.format(new Date(timestamp)) ?? undefined}>
{children}
</abbr>
);
}

View File

@@ -0,0 +1,12 @@
.section {
& > div {
padding: var(--s-2);
border-radius: var(--radius-box);
background-color: var(--color-bg);
}
& > h2 {
color: var(--color-text-high);
font-size: var(--font-md);
}
}

View File

@@ -0,0 +1,18 @@
import styles from "./Section.module.css";
export function Section({
title,
children,
className,
}: {
title?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<section className={styles.section}>
{title && <h2>{title}</h2>}
<div className={className}>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,44 @@
import { Share2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { CopyToClipboardPopover } from "./CopyToClipboardPopover";
export function ShareUrlButton({
url,
...buttonProps
}: { url: string } & React.ComponentProps<typeof SendouButton>) {
const { t } = useTranslation(["common"]);
const canNativeShare =
typeof navigator !== "undefined" && typeof navigator.share === "function";
if (canNativeShare) {
return (
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
onPress={() => navigator.share({ url })}
aria-label={t("common:actions.share")}
{...buttonProps}
/>
);
}
return (
<CopyToClipboardPopover
url={url}
trigger={
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
aria-label={t("common:actions.share")}
{...buttonProps}
/>
}
/>
);
}

View File

@@ -0,0 +1,308 @@
.sideNav {
background-color: var(--color-bg-nav);
min-width: var(--layout-sidenav-width);
max-width: var(--layout-sidenav-width);
border-right: 1.5px solid var(--color-border);
overflow: hidden;
position: sticky;
top: 0;
left: 0;
height: 100dvh;
display: none;
flex-direction: column;
@media screen and (min-width: 1000px) {
display: flex;
}
}
.sideNavCollapsed {
display: none;
}
.sideNavTop {
height: var(--layout-nav-height);
background-color: var(--color-bg-nav);
border-bottom: 1.5px solid var(--color-border);
display: flex;
align-items: center;
padding-inline: var(--s-2);
flex-shrink: 0;
}
.sideNavTopCentered {
justify-content: center;
}
.sideNavInner {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: var(--s-1-5);
padding-block-end: var(--s-2);
overflow-x: hidden;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.sideNavFooter {
height: var(--layout-nav-height);
display: flex;
align-items: center;
gap: var(--s-1);
padding: var(--s-1-5) var(--s-3);
background-color: var(--color-bg-nav);
border-top: 1.5px solid var(--color-border);
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);
padding: var(--s-1-5);
}
.listLink {
font-size: var(--font-xs);
color: var(--color-text);
text-decoration: none;
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
transition:
background-color 0.15s,
color 0.15s;
display: flex;
align-items: center;
gap: var(--s-2);
&:hover {
&:not(:has(.listLinkSubtitle)) {
color: var(--color-text);
}
background-color: var(--color-bg-high);
}
&[aria-current="page"] {
color: var(--color-text);
background-color: var(--color-bg-higher);
font-weight: var(--weight-bold);
}
}
.listButton {
composes: listLink;
background: none;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
&:focus-visible {
outline: var(--focus-ring);
}
}
.listLinkImageContainer {
position: relative;
flex-shrink: 0;
}
.listLinkImage {
width: 32px;
height: 32px;
border-radius: var(--radius-avatar);
object-fit: cover;
flex-shrink: 0;
}
.listLinkOverlayIcon {
position: absolute;
bottom: -2px;
right: -2px;
width: 16px;
height: 16px;
border-radius: var(--radius-field);
background-color: var(--color-bg);
padding: 2px;
}
.listLinkContent {
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--s-0-5);
width: 100%;
}
.listLinkTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:has(+ .listLinkSubtitle) {
color: var(--color-text);
}
}
.listLinkSubtitleRow {
display: flex;
align-items: center;
gap: var(--s-1-5);
width: 100%;
color: var(--color-text-high);
}
.listLinkSubtitle {
font-size: var(--font-2xs);
color: var(--color-text-high);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.listLinkBadge {
display: flex;
align-items: center;
margin-left: auto;
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-inverse);
background-color: var(--color-text-accent);
padding: 0 var(--s-1);
border-radius: var(--radius-selector);
height: var(--selector-size-xs);
text-align: center;
flex-shrink: 0;
text-transform: uppercase;
}
.listLinkBadgeWarning {
background-color: var(--color-text-second);
}
@media screen and (min-width: 1475px) {
:global(html[data-fuse="true"]) .sideNav {
z-index: 20;
}
}

View File

@@ -0,0 +1,220 @@
import clsx from "clsx";
import { X } from "lucide-react";
import type * as React from "react";
import { Button } from "react-aria-components";
import { Link } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import type { Tables } from "~/db/tables";
import { Avatar } from "./Avatar";
import styles from "./SideNav.module.css";
export function SideNav({
children,
className,
footer,
top,
topCentered,
collapsed,
}: {
children: React.ReactNode;
className?: string;
footer?: React.ReactNode;
top?: React.ReactNode;
topCentered?: boolean;
collapsed?: boolean;
}) {
return (
<nav
className={clsx(styles.sideNav, className, {
[styles.sideNavCollapsed]: collapsed,
})}
>
<div
className={clsx(styles.sideNavTop, {
[styles.sideNavTopCentered]: topCentered,
})}
>
{top}
</div>
<div className={clsx(styles.sideNavInner, "scrollbar")}>{children}</div>
{footer}
</nav>
);
}
export function SideNavHeader({
children,
icon,
showClose,
action,
}: {
children: React.ReactNode;
icon?: React.ReactNode;
showClose?: boolean;
action?: React.ReactNode;
}) {
return (
<header className={styles.sideNavHeader}>
{icon ? <div className={styles.iconContainer}>{icon}</div> : null}
<h2>{children}</h2>
{action ? (
<span className={styles.sideNavHeaderAction}>{action}</span>
) : null}
{showClose ? (
<SendouButton
icon={<X />}
variant="minimal"
slot="close"
className={styles.sideNavHeaderClose}
/>
) : null}
</header>
);
}
function ListItemContent({
children,
user,
imageUrl,
overlayIconUrl,
subtitle,
badge,
badgeVariant,
suppressSubtitleHydrationWarning,
}: {
children: React.ReactNode;
user?: Pick<Tables["User"], "discordId" | "discordAvatar"> & {
customAvatarUrl?: string | null;
};
imageUrl?: string;
overlayIconUrl?: string;
subtitle?: React.ReactNode;
badge?: React.ReactNode;
badgeVariant?: "default" | "warning";
suppressSubtitleHydrationWarning?: boolean;
}) {
return (
<>
{user ? (
<Avatar user={user} size="xxsm" />
) : imageUrl ? (
<div className={styles.listLinkImageContainer}>
<img src={imageUrl} alt="" className={styles.listLinkImage} />
{overlayIconUrl ? (
<img
src={overlayIconUrl}
alt=""
className={styles.listLinkOverlayIcon}
/>
) : null}
</div>
) : null}
<div className={styles.listLinkContent}>
<span className={styles.listLinkTitle}>{children}</span>
{subtitle || badge ? (
<div className={styles.listLinkSubtitleRow}>
{subtitle ? (
<span
className={styles.listLinkSubtitle}
suppressHydrationWarning={suppressSubtitleHydrationWarning}
>
{subtitle}
</span>
) : null}
{typeof badge === "string" ? (
<span
className={clsx(styles.listLinkBadge, {
[styles.listLinkBadgeWarning]: badgeVariant === "warning",
})}
>
{badge}
</span>
) : (
badge
)}
</div>
) : null}
</div>
</>
);
}
export function ListLink({
children,
to,
onClick,
isActive,
imageUrl,
overlayIconUrl,
user,
subtitle,
badge,
badgeVariant,
}: {
children: React.ReactNode;
to: string;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
isActive?: boolean;
imageUrl?: string;
overlayIconUrl?: string;
user?: Pick<Tables["User"], "discordId" | "discordAvatar"> & {
customAvatarUrl?: string | null;
};
subtitle?: React.ReactNode;
badge?: React.ReactNode;
badgeVariant?: "default" | "warning";
}) {
return (
<Link
to={to}
className={styles.listLink}
onClick={onClick}
aria-current={isActive ? "page" : undefined}
>
<ListItemContent
user={user}
imageUrl={imageUrl}
overlayIconUrl={overlayIconUrl}
subtitle={subtitle}
badge={badge}
badgeVariant={badgeVariant}
suppressSubtitleHydrationWarning
>
{children}
</ListItemContent>
</Link>
);
}
export function ListButton({
children,
user,
subtitle,
badge,
badgeVariant,
}: {
children: React.ReactNode;
user?: Pick<Tables["User"], "discordId" | "discordAvatar"> & {
customAvatarUrl?: string | null;
};
subtitle?: string | null;
badge?: string | null;
badgeVariant?: "default" | "warning";
}) {
return (
<Button className={styles.listButton}>
<ListItemContent
user={user}
subtitle={subtitle}
badge={badge}
badgeVariant={badgeVariant}
>
{children}
</ListItemContent>
</Button>
);
}
export function SideNavFooter({ children }: { children: React.ReactNode }) {
return <div className={styles.sideNavFooter}>{children}</div>;
}

View File

@@ -0,0 +1,26 @@
.sortHeader {
display: inline-flex;
align-items: center;
gap: var(--s-1);
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-transform: inherit;
}
.sortIcon {
width: 0.85rem;
height: 0.85rem;
}
.sortIconInactive {
opacity: 0.4;
.sortHeader:hover &,
.sortHeader:focus-visible & {
opacity: 0.85;
}
}

View File

@@ -0,0 +1,57 @@
import clsx from "clsx";
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
import styles from "./SortableTableHeader.module.css";
export type SortDirection = "asc" | "desc";
export type SortState<Key extends string = string> = {
key: Key;
dir: SortDirection;
} | null;
export function SortableTableHeader<Key extends string>({
label,
sortKey,
sort,
onChange,
}: {
label: string;
sortKey: Key;
sort: SortState<Key>;
onChange: (next: SortState<Key>) => void;
}) {
const active = sort?.key === sortKey;
return (
<th>
<button
type="button"
className={styles.sortHeader}
onClick={() => onChange(nextSortState(sort, sortKey))}
>
{label}
{active ? (
sort.dir === "asc" ? (
<ArrowUp className={styles.sortIcon} />
) : (
<ArrowDown className={styles.sortIcon} />
)
) : (
<ChevronsUpDown
className={clsx(styles.sortIcon, styles.sortIconInactive)}
aria-hidden
/>
)}
</button>
</th>
);
}
function nextSortState<Key extends string>(
current: SortState<Key>,
key: Key,
): SortState<Key> {
if (current?.key !== key) return { key, dir: "asc" };
if (current.dir === "asc") return { key, dir: "desc" };
return null;
}

View File

@@ -0,0 +1,17 @@
.banner {
background-image:
linear-gradient(
to right,
var(--stage-banner-fade, var(--color-bg-high)) 35%,
transparent 80%
),
var(--stage-banner);
background-origin: border-box;
background-position: right center;
/* fade layer is grown a pixel past the box so subpixel box sizes can't
leave a sliver of the banner showing at the edges */
background-size:
calc(100% + 2px) calc(100% + 2px),
cover;
background-repeat: no-repeat;
}

View File

@@ -0,0 +1,33 @@
import clsx from "clsx";
import type * as React from "react";
import type { StageId } from "~/modules/in-game-lists/types";
import { stageBannerImageUrl } from "~/utils/urls";
import styles from "./StageBannerBox.module.css";
/**
* Box with a stage banner image fading in from the right. The fade color
* defaults to `--color-bg-high`; override per use with the
* `--stage-banner-fade` CSS variable.
*/
export function StageBannerBox({
stageId,
className,
children,
}: {
stageId: StageId;
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={clsx(styles.banner, className)}
style={
{
"--stage-banner": `url(${stageBannerImageUrl(stageId)})`,
} as React.CSSProperties
}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,16 @@
.item {
display: flex;
gap: var(--s-2);
align-items: center;
}
.stageImg {
border-radius: var(--radius-field);
}
.stageLabel {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}

View File

@@ -0,0 +1,89 @@
import type { Key } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import { StageImage } from "~/components/Image";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { StageId } from "~/modules/in-game-lists/types";
import styles from "./StageSelect.module.css";
interface StageSelectProps<Clearable extends boolean | undefined = undefined> {
label?: string;
value?: StageId | null;
initialValue?: StageId;
onChange?: (
stageId: StageId | (Clearable extends true ? null : never),
) => void;
clearable?: Clearable;
testId?: string;
isRequired?: boolean;
isDisabled?: boolean;
}
export function StageSelect<Clearable extends boolean | undefined = undefined>({
label,
value,
initialValue,
onChange,
clearable,
testId = "stage-select",
isRequired,
isDisabled,
}: StageSelectProps<Clearable>) {
const { t } = useTranslation(["common", "game-misc"]);
const items = useStageItems();
const isControlled = value !== undefined;
const handleOnChange = (key: Key | null) => {
if (key === null) return onChange?.(null as any);
onChange?.(Number(key) as any);
};
return (
<SendouSelect
aria-label={
!label ? t("common:forms.stageSearch.placeholder") : undefined
}
items={items}
label={label}
placeholder={t("common:forms.stageSearch.placeholder")}
search={{
placeholder: t("common:forms.stageSearch.search.placeholder"),
}}
selectedKey={isControlled ? value : undefined}
defaultSelectedKey={isControlled ? undefined : (initialValue as Key)}
onSelectionChange={handleOnChange}
clearable={clearable}
data-testid={testId}
isRequired={isRequired}
isDisabled={isDisabled}
>
{({ id, name }) => (
<SendouSelectItem key={id} id={id} textValue={name}>
<div className={styles.item}>
<StageImage
stageId={id as StageId}
width={42}
className={styles.stageImg}
/>
<span
className={styles.stageLabel}
data-testid={`stage-select-option-${name}`}
>
{name}
</span>
</div>
</SendouSelectItem>
)}
</SendouSelect>
);
}
function useStageItems() {
const { t } = useTranslation(["game-misc"]);
return stageIds.map((id) => ({
id,
name: t(`game-misc:STAGE_${id}`),
}));
}

View File

@@ -0,0 +1,58 @@
.xpSubtitle {
display: flex;
align-items: center;
gap: 2px;
}
.xpIcon {
width: 14px;
height: 14px;
}
.tierBadge {
flex-shrink: 0;
}
.upcomingDivider {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-1) var(--s-2);
font-size: var(--font-3xs);
color: var(--color-text-high);
text-transform: uppercase;
letter-spacing: 0.05em;
&::before,
&::after {
content: "";
flex: 1;
border-top: 1px solid var(--color-border);
}
}
.badgeRow {
display: flex;
align-items: center;
gap: var(--s-1);
margin-left: auto;
flex-shrink: 0;
}
.saveIconButton {
display: grid;
place-items: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-high);
padding: 0;
height: var(--selector-size-sm);
aspect-ratio: 1 / 1;
border-radius: var(--radius-selector);
&:hover {
color: var(--color-text);
background-color: var(--color-bg-higher);
}
}

View File

@@ -0,0 +1,190 @@
import { isToday, isTomorrow } from "date-fns";
import { Bookmark, BookmarkCheck } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import type { SidebarStream } from "~/features/core/streams/streams.server";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { useHydrated } from "~/hooks/useHydrated";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl, tournamentInfoPage } from "~/utils/urls";
import { Image } from "./Image";
import { ListLink } from "./SideNav";
import styles from "./StreamListItems.module.css";
import { TierPill } from "./TierPill";
export function StreamListItems({
streams,
onClick,
isLoggedIn,
savedTournamentIds,
}: {
streams: SidebarStream[];
onClick?: () => void;
isLoggedIn?: boolean;
savedTournamentIds?: number[];
}) {
const { t, i18n } = useTranslation(["front"]);
const formatDistanceToNow = useFormatDistanceToNow();
const { formatter: timeFormatter } = useDateTimeFormat({
hour: "numeric",
minute: "numeric",
});
const { formatter: dateTimeFormatter } = useDateTimeFormat({
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
});
const isHydrated = useHydrated();
const formatRelativeDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const timeStr = timeFormatter.format(date);
if (isToday(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto",
});
const dayStr = rtf.format(0, "day");
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
}
if (isTomorrow(date)) {
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
numeric: "auto",
});
const dayStr = rtf.format(1, "day");
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
}
return dateTimeFormatter.format(date);
};
return (
<>
{streams.map((stream, i) => {
const startsAtDate = databaseTimestampToDate(stream.startsAt);
const isUpcoming = startsAtDate.getTime() > Date.now();
const prevStream = streams.at(i - 1);
const prevIsLive =
prevStream &&
databaseTimestampToDate(prevStream.startsAt).getTime() <= Date.now();
const showUpcomingDivider = isUpcoming && prevIsLive;
const tournamentId = stream.id.startsWith("upcoming-")
? Number(stream.id.replace("upcoming-", ""))
: null;
return (
<React.Fragment key={stream.id}>
{showUpcomingDivider ? (
<div className={styles.upcomingDivider}>
{t("front:sideNav.streams.upcoming")}
</div>
) : null}
<ListLink
to={stream.url}
imageUrl={stream.imageUrl}
overlayIconUrl={stream.overlayIconUrl}
subtitle={
stream.peakXp ? (
<span className={styles.xpSubtitle}>
<Image
path={navIconUrl("xsearch")}
alt=""
className={styles.xpIcon}
/>
{stream.peakXp}
</span>
) : stream.subtitle ? (
stream.subtitle
) : !isHydrated ? (
<span className="invisible">Placeholder</span>
) : isUpcoming ? (
formatRelativeDate(stream.startsAt)
) : (
formatDistanceToNow(startsAtDate, {
addSuffix: true,
})
)
}
badge={
!isUpcoming ? (
"LIVE"
) : (
<div className={styles.badgeRow}>
{isLoggedIn && tournamentId !== null ? (
<SaveTournamentStreamButton
tournamentId={tournamentId}
isSaved={
savedTournamentIds?.includes(tournamentId) ?? false
}
/>
) : null}
{streamTierBadge(stream)}
</div>
)
}
badgeVariant={!isUpcoming ? "warning" : undefined}
onClick={onClick}
>
{stream.name}
</ListLink>
</React.Fragment>
);
})}
</>
);
}
function SaveTournamentStreamButton({
tournamentId,
isSaved,
}: {
tournamentId: number;
isSaved: boolean;
}) {
const fetcher = useFetcher();
const optimisticSaved =
fetcher.formData?.get("_action") === "SAVE_TOURNAMENT"
? true
: fetcher.formData?.get("_action") === "UNSAVE_TOURNAMENT"
? false
: isSaved;
const Icon = optimisticSaved ? BookmarkCheck : Bookmark;
return (
<fetcher.Form
method="post"
action={tournamentInfoPage(tournamentId)}
onClick={(e) => e.stopPropagation()}
>
<input
type="hidden"
// biome-ignore lint/plugin: bare icon button that also posts revalidateRoot for the root loader
name="_action"
value={optimisticSaved ? "UNSAVE_TOURNAMENT" : "SAVE_TOURNAMENT"}
/>
<input type="hidden" name="revalidateRoot" value="true" />
<button type="submit" className={styles.saveIconButton} title="Save">
<Icon size={14} />
</button>
</fetcher.Form>
);
}
function streamTierBadge(stream: SidebarStream): React.ReactNode {
const tier = stream.tier ?? stream.tentativeTier;
if (!tier) return undefined;
return (
<div className={styles.tierBadge}>
<TierPill
tier={tier}
isTentative={!stream.tier && !!stream.tentativeTier}
/>
</div>
);
}

View File

@@ -0,0 +1,90 @@
.container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--s-4);
margin-block-end: var(--s-8);
}
.secondary {
gap: var(--s-1);
margin-block-end: 0;
}
.linkContainer {
display: flex;
max-width: 110px;
flex: 1;
flex-direction: column;
align-items: center;
color: var(--color-text);
gap: var(--s-1-5);
&.active {
color: var(--color-text-accent);
}
}
.link {
width: 100%;
padding: 0 var(--s-4);
height: var(--field-size-sm);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-field);
font-weight: var(--weight-semi);
background-color: var(--color-bg-high);
cursor: pointer;
font-size: var(--font-xs);
text-align: center;
white-space: nowrap;
}
.linkSecondary {
height: var(--selector-size-sm);
border-radius: var(--radius-selector);
padding: 0 var(--s-2);
font-size: var(--font-xs);
color: var(--color-text-high);
background-color: transparent;
}
.borderGuy {
width: 78%;
height: 3px;
border-radius: var(--radius-box);
background-color: var(--color-bg-higher);
visibility: hidden;
}
.borderGuySecondary {
height: 2.5px;
background-color: var(--color-bg-high);
}
.container.compact .link {
padding: var(--s-1) var(--s-2);
}
.linkContainer.active > .borderGuy {
visibility: initial;
}
.secondary .linkContainer {
max-width: none;
flex: none;
}
.secondary .linkContainer.active {
color: var(--color-text);
}
.secondary .linkContainer.active .linkSecondary {
background-color: var(--color-bg-higher);
color: var(--color-text);
}
.secondary .borderGuy {
display: none;
}

View File

@@ -0,0 +1,69 @@
import clsx from "clsx";
import type * as React from "react";
import type { LinkProps } from "react-router";
import { NavLink } from "react-router";
import styles from "./SubNav.module.css";
export function SubNav({
children,
secondary,
className,
}: {
children: React.ReactNode;
secondary?: boolean;
className?: string;
}) {
return (
<div>
<nav
className={clsx(styles.container, className, {
[styles.secondary]: secondary,
})}
>
{children}
</nav>
</div>
);
}
export function SubNavLink({
children,
className,
end = true,
secondary = false,
controlled = false,
active = false,
...props
}: LinkProps & {
end?: boolean;
children: React.ReactNode;
secondary?: boolean;
controlled?: boolean;
active?: boolean;
}) {
return (
<NavLink
className={(state) =>
clsx(styles.linkContainer, {
[styles.active]: controlled ? active : state.isActive,
pending: state.isPending,
})
}
end={end}
{...props}
>
<div
className={clsx(styles.link, className, {
[styles.linkSecondary]: secondary,
})}
>
{children}
</div>
<div
className={clsx(styles.borderGuy, {
[styles.borderGuySecondary]: secondary,
})}
/>
</NavLink>
);
}

View File

@@ -0,0 +1,58 @@
import { type FetcherWithComponents, useNavigation } from "react-router";
import type { z } from "zod";
import type { ActionsOf } from "~/utils/action-schemas";
import { SendouButton, type SendouButtonProps } from "./elements/Button";
type SubmitButtonProps<TSchema extends z.ZodTypeAny> = SendouButtonProps & {
/** If the page has multiple forms you can pass in fetcher.state to differentiate when this SubmitButton should be in submitting state */
state?: FetcherWithComponents<any>["state"];
testId?: string;
} & (
| {
/** Action schema of the route the form submits to. Only used for typing `_action`. */
schema: TSchema;
/** `_action` to submit, narrowed to the literals of the schema. */
_action: ActionsOf<TSchema>;
}
| { schema?: never; _action?: never }
);
export function SubmitButton<TSchema extends z.ZodTypeAny>({
children,
state,
schema: _schema,
_action,
testId,
...rest
}: SubmitButtonProps<TSchema>) {
const navigation = useNavigation();
const isSubmitting = state ? state !== "idle" : navigation.state !== "idle";
const name = () => {
if (rest.name) return rest.name;
if (_action) return "_action";
return undefined;
};
const value = () => {
if (rest.value) return rest.value;
if (_action) return _action;
return undefined;
};
return (
<SendouButton
{...rest}
isDisabled={rest.isDisabled || isSubmitting}
type="submit"
name={name()}
value={value()}
data-testid={testId ?? "submit-button"}
>
{children}
</SendouButton>
);
}

View File

@@ -0,0 +1,39 @@
.container {
position: relative;
width: 100%;
overflow: auto;
}
.table {
width: max-content;
min-width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--font-xs);
text-align: left;
border-color: var(--color-border);
& > thead {
font-size: var(--font-2xs);
}
&:not(.noRowHover) tbody tr:hover {
background-color: var(--color-bg-high);
}
& > thead > tr > th {
padding: var(--s-2);
}
& > tbody > tr > td {
padding: var(--s-2) var(--s-3);
}
& tr:first-child td {
border-top: 1px solid var(--color-border);
}
& td {
border-bottom: 1px solid var(--color-border);
}
}

View File

@@ -0,0 +1,20 @@
import clsx from "clsx";
import styles from "./Table.module.css";
export function Table({
children,
noRowHover,
}: {
children: React.ReactNode;
noRowHover?: boolean;
}) {
return (
<div className={styles.container}>
<table
className={clsx(styles.table, { [styles.noRowHover]: noRowHover })}
>
{children}
</table>
</div>
);
}

View File

@@ -0,0 +1,131 @@
:root {
--tier-bg-1: hsl(45, 100%, 47%);
--tier-bg-2: hsl(280, 90%, 44%);
--tier-bg-3: hsl(280, 65%, 52%);
--tier-bg-4: hsl(212, 95%, 44%);
--tier-bg-5: hsl(212, 80%, 51%);
--tier-bg-6: hsl(145, 80%, 34%);
--tier-bg-7: hsl(145, 60%, 41%);
--tier-bg-8: hsl(220, 10%, 40%);
--tier-bg-9: hsl(220, 8%, 49%);
--tier-text-1: hsl(45, 100%, 12%);
--tier-text-2: hsl(280, 100%, 94%);
--tier-text-3: hsl(280, 100%, 95%);
--tier-text-4: hsl(212, 100%, 93%);
--tier-text-5: hsl(212, 100%, 95%);
--tier-text-6: hsl(145, 85%, 92%);
--tier-text-7: hsl(145, 75%, 94%);
--tier-text-8: hsl(220, 15%, 92%);
--tier-text-9: hsl(220, 15%, 95%);
}
.pill {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
border-radius: var(--radius-selector);
padding: 0 var(--s-1-5);
height: var(--selector-size-sm);
display: grid;
place-items: center;
min-width: 33px;
text-align: center;
}
.tierX {
--_polished-bg: var(--tier-bg-1);
background-color: var(--tier-bg-1);
color: var(--tier-text-1);
}
.tierSPlus {
--_polished-bg: var(--tier-bg-2);
background-color: var(--tier-bg-2);
color: var(--tier-text-2);
}
.tierS {
--_polished-bg: var(--tier-bg-3);
background-color: var(--tier-bg-3);
color: var(--tier-text-3);
}
.tierAPlus {
background-color: var(--tier-bg-4);
color: var(--tier-text-4);
}
.tierA {
background-color: var(--tier-bg-5);
color: var(--tier-text-5);
}
.tierBPlus {
background-color: var(--tier-bg-6);
color: var(--tier-text-6);
}
.tierB {
background-color: var(--tier-bg-7);
color: var(--tier-text-7);
}
.tierCPlus {
background-color: var(--tier-bg-8);
color: var(--tier-text-8);
}
.tierC {
background-color: var(--tier-bg-9);
color: var(--tier-text-9);
}
.polished {
position: relative;
overflow: hidden;
background-image: linear-gradient(
160deg,
hsl(from var(--_polished-bg) calc(h + 2) s calc(l + 14)),
var(--_polished-bg) 50%,
hsl(from var(--_polished-bg) calc(h - 3) s calc(l - 9))
);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 35%,
hsl(0, 0%, 100%, 0.5) 50%,
transparent 65%
);
transform: translateX(-100%);
animation: shine-sweep 6s ease-in-out infinite;
}
}
@keyframes shine-sweep {
0% {
transform: translateX(-100%);
}
12% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
.withoutAnimation::after {
animation: none;
visibility: hidden;
}
@media (prefers-reduced-motion: reduce) {
.polished::after {
animation: none;
visibility: hidden;
}
}

View File

@@ -0,0 +1,50 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { tierNumberToName } from "~/features/tournament/core/tiering";
import styles from "./TierPill.module.css";
const TIER_STYLE_CLASS: Record<number, string> = {
1: styles.tierX,
2: styles.tierSPlus,
3: styles.tierS,
4: styles.tierAPlus,
5: styles.tierA,
6: styles.tierBPlus,
7: styles.tierB,
8: styles.tierCPlus,
9: styles.tierC,
};
const POLISHED_TIERS = [1, 2, 3];
export function TierPill({
tier,
isTentative = false,
withoutAnimation = false,
}: {
tier: number;
isTentative?: boolean;
withoutAnimation?: boolean;
}) {
const { t } = useTranslation();
const tierName = tierNumberToName(tier);
const tierClass = TIER_STYLE_CLASS[tier] ?? "";
const displayName = isTentative ? `~${tierName}` : tierName;
return (
<div
className={clsx(styles.pill, tierClass, {
[styles.polished]: POLISHED_TIERS.includes(tier),
[styles.withoutAnimation]: withoutAnimation,
})}
data-testid={isTentative ? "tentative-tier" : "confirmed-tier"}
title={
isTentative
? t("tier.tentative", { tierName })
: t("tier.confirmed", { tierName })
}
>
{displayName}
</div>
);
}

View File

@@ -0,0 +1,14 @@
.textOnlyButton {
cursor: pointer;
border: 0;
background-color: inherit;
color: inherit;
margin: 0;
padding: 0;
}
.dotted {
text-decoration-style: dotted;
text-decoration-line: underline;
text-decoration-thickness: 2px;
}

View File

@@ -0,0 +1,91 @@
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 { LocaleTime } from "./LocaleTime";
import styles from "./TimePopover.module.css";
export default function TimePopover({
date,
options = {
minute: "numeric",
hour: "numeric",
day: "numeric",
month: "numeric",
},
underline = true,
className,
footerText,
}: {
date: Date;
options?: Intl.DateTimeFormatOptions;
underline?: boolean;
className?: string;
footerText?: string;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const { t } = useTranslation(["common"]);
const { copyToClipboard, copySuccess } = useCopyToClipboard();
return (
<div>
<button
type="button"
ref={triggerRef}
className={clsx(
className,
"clickable",
styles.textOnlyButton,
underline ? styles.dotted : "",
)}
onClick={() => {
setOpen(true);
}}
>
<LocaleTime date={date} options={options} inline />
</button>
<Popover
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>
</Dialog>
</Popover>
</div>
);
}

View File

@@ -0,0 +1,10 @@
.userLink {
display: inline-flex;
align-items: center;
gap: var(--s-1-5);
color: var(--color-text);
&.vertical {
flex-direction: column;
}
}

View File

@@ -0,0 +1,53 @@
import clsx from "clsx";
import type * as React from "react";
import { Link } from "react-router";
import type { Tables } from "~/db/tables";
import { userPage } from "~/utils/urls";
import { Avatar } from "./Avatar";
import styles from "./UserLink.module.css";
type UserLinkUser = Pick<
Tables["User"],
"username" | "discordId" | "discordAvatar"
> & {
customUrl?: Tables["User"]["customUrl"];
customAvatarUrl?: string | null;
};
type UnlinkedPlayer = { name: string | null } & {
[K in keyof UserLinkUser]: UserLinkUser[K] | null;
};
/** Link to a user's page showing their avatar and username. Accepts also result players without an account, rendering just their name. */
export function UserLink({
user,
size = "xxs",
direction = "horizontal",
className,
children,
}: {
user: UserLinkUser | UnlinkedPlayer;
size?: React.ComponentProps<typeof Avatar>["size"];
direction?: "horizontal" | "vertical";
className?: string;
/** Replaces the plain username text (avatar is always rendered) */
children?: React.ReactNode;
}) {
if (user.username === null) {
return <>{(user as UnlinkedPlayer).name}</>;
}
const linkedUser = user as UserLinkUser;
return (
<Link
to={userPage(linkedUser)}
className={clsx(styles.userLink, className, {
[styles.vertical]: direction === "vertical",
})}
>
<Avatar user={linkedUser} size={size} />
{children ?? linkedUser.username}
</Link>
);
}

View File

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

View File

@@ -0,0 +1,426 @@
import type { TFunction } from "i18next";
import * as React from "react";
import type { Key } from "react-aria-components";
import { useTranslation } from "react-i18next";
import {
SendouSelect,
SendouSelectItem,
SendouSelectItemSection,
} from "~/components/elements/Select";
import { Image, WeaponImage } from "~/components/Image";
import type { AnyWeapon } from "~/features/build-analyzer/analyzer-types";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { filterWeapon } from "~/modules/in-game-lists/utils";
import {
mainWeaponIds,
nonDamagingSpecialWeaponIds,
SPLAT_BOMB_ID,
specialWeaponIds,
subWeaponIds,
TRIZOOKA_ID,
weaponCategories,
} from "~/modules/in-game-lists/weapon-ids";
import {
specialWeaponImageUrl,
subWeaponImageUrl,
weaponCategoryUrl,
} from "~/utils/urls";
import styles from "./WeaponSelect.module.css";
interface WeaponSelectProps<
Clearable extends boolean | undefined = undefined,
IncludeSubSpecial extends boolean | undefined = undefined,
> {
label?: string;
value?: (IncludeSubSpecial extends true ? AnyWeapon : MainWeaponId) | null;
initialValue?: IncludeSubSpecial extends true ? AnyWeapon : MainWeaponId;
onChange?: (
weaponId:
| (IncludeSubSpecial extends true ? AnyWeapon : MainWeaponId)
| (Clearable extends true ? null : never),
) => void;
clearable?: Clearable;
includeSubSpecial?: IncludeSubSpecial;
disabledWeaponIds?: Array<MainWeaponId>;
testId?: string;
isRequired?: boolean;
/** If set, selection of weapons that user sees when search input is empty allowing for quick select for e.g. previous selections */
quickSelectWeaponsIds?: Array<MainWeaponId>;
isDisabled?: boolean;
placeholder?: string;
}
export function WeaponSelect<
Clearable extends boolean | undefined = undefined,
IncludeSubSpecial extends boolean | undefined = undefined,
>({
label,
value,
initialValue,
onChange,
disabledWeaponIds,
clearable,
includeSubSpecial,
testId = "weapon-select",
isRequired,
quickSelectWeaponsIds,
isDisabled,
placeholder,
}: WeaponSelectProps<Clearable, IncludeSubSpecial>) {
const { t } = useTranslation(["common"]);
const selectedWeaponId: MainWeaponId | null =
typeof value === "number"
? (value as MainWeaponId)
: value && typeof value === "object" && value.type === "MAIN"
? (value.id as MainWeaponId)
: null;
const isControlled = value !== undefined;
const [isOpen, setIsOpen] = React.useState(false);
const [lastUncontrolledKey, setLastUncontrolledKey] = React.useState<
string | null
>(() => keyify(initialValue) ?? null);
const selectedKey = isControlled ? keyify(value) : lastUncontrolledKey;
const { items, filterValue, setFilterValue } = useWeaponItems({
includeSubSpecial,
quickSelectWeaponsIds,
selectedWeaponId,
isOpen,
selectedKey,
});
const filter = useWeaponFilter();
const handleOnChange = (key: Key | null) => {
if (!isControlled) {
setLastUncontrolledKey(key === null ? null : String(key));
}
if (key === null) return onChange?.(null as any);
const [type, id] = (key as string).split("_");
const weapon = {
id: Number(id),
type: type as "MAIN" | "SUB" | "SPECIAL",
} as AnyWeapon;
if (!includeSubSpecial) return onChange?.(weapon.id as any); // plain main weapon id
onChange?.(weapon as any);
};
return (
<SendouSelect
aria-label={
!label ? t("common:forms.weaponSearch.placeholder") : undefined
}
isDisabled={isDisabled}
items={items}
label={label}
placeholder={placeholder ?? t("common:forms.weaponSearch.placeholder")}
search={{
placeholder: t("common:forms.weaponSearch.search.placeholder"),
}}
searchInputValue={filterValue}
onSearchInputChange={setFilterValue}
onOpenChange={setIsOpen}
selectedKey={isControlled ? keyify(value) : undefined}
defaultSelectedKey={
isControlled ? undefined : (keyify(initialValue) as Key)
}
onSelectionChange={handleOnChange}
clearable={clearable}
data-testid={testId}
isRequired={isRequired}
filter={filter}
>
{({ key, items: weapons, name, idx }) => (
<SendouSelectItemSection
heading={name}
headingImgPath={
key === "quick-select"
? undefined
: name === "subs"
? subWeaponImageUrl(SPLAT_BOMB_ID)
: name === "specials"
? specialWeaponImageUrl(TRIZOOKA_ID)
: weaponCategoryUrl(name)
}
className={idx === 0 ? "pt-0-5" : undefined}
key={key}
>
{weapons.map(({ weapon, name }) => (
<SendouSelectItem
key={weapon.anyWeaponId}
id={weapon.anyWeaponId}
textValue={name}
isDisabled={
includeSubSpecial
? false
: disabledWeaponIds?.includes(weapon.id as MainWeaponId)
}
>
<div className={styles.item}>
{weapon.type === "MAIN" ? (
<WeaponImage
weaponSplId={weapon.id}
variant="build"
size={24}
className={styles.weaponImg}
/>
) : weapon.type === "SUB" ? (
<Image
path={subWeaponImageUrl(weapon.id)}
size={24}
alt=""
className={styles.weaponImg}
/>
) : (
<Image
path={specialWeaponImageUrl(weapon.id)}
size={24}
alt=""
className={styles.weaponImg}
/>
)}
<span
className={styles.weaponLabel}
data-testid={`weapon-select-option-${name}`}
>
{name}
</span>
</div>
</SendouSelectItem>
))}
</SendouSelectItemSection>
)}
</SendouSelect>
);
}
const weaponNameToWeaponMapCache = new Map<string, Map<string, AnyWeapon>>();
function useWeaponFilter() {
const { t, i18n } = useTranslation(["weapons"]);
const cached = weaponNameToWeaponMapCache.get(i18n.language);
const weaponNameToWeaponMap = cached ?? buildWeaponNameToWeaponMap(t);
if (!cached && i18n.hasLoadedNamespace("weapons")) {
weaponNameToWeaponMapCache.set(i18n.language, weaponNameToWeaponMap);
}
return (value: string, searchValue: string) => {
const weapon = weaponNameToWeaponMap.get(value);
if (!weapon) return false;
return filterWeapon({
weapon,
weaponName: value,
searchTerm: searchValue,
});
};
}
function buildWeaponNameToWeaponMap(t: TFunction<["weapons"]>) {
const map = new Map<string, AnyWeapon>();
for (const id of mainWeaponIds) {
map.set(t(`weapons:MAIN_${id}`), { id, type: "MAIN" });
}
for (const id of subWeaponIds) {
map.set(t(`weapons:SUB_${id}`), { id, type: "SUB" });
}
for (const id of specialWeaponIds) {
map.set(t(`weapons:SPECIAL_${id}`), { id, type: "SPECIAL" });
}
return map;
}
function useWeaponItems({
includeSubSpecial,
quickSelectWeaponsIds,
selectedWeaponId,
isOpen,
selectedKey,
}: {
includeSubSpecial: boolean | undefined;
quickSelectWeaponsIds?: Array<MainWeaponId>;
selectedWeaponId?: MainWeaponId | null;
isOpen: boolean;
selectedKey: string | null | undefined;
}) {
const items = useAllWeaponCategories(includeSubSpecial);
const [filterValue, setFilterValue] = React.useState("");
const { t } = useTranslation(["common"]);
// While closed only the selected item is needed (the trigger's value
// display); react-aria renders every item passed to it into a hidden
// collection even when the popover is closed.
if (!isOpen) {
return {
items: collapseToSelectedItem(items, selectedKey),
filterValue,
setFilterValue,
};
}
const showQuickSelectWeapons =
filterValue === "" && quickSelectWeaponsIds?.length;
if (showQuickSelectWeapons) {
const weaponIdsToInclude = new Set(quickSelectWeaponsIds);
if (typeof selectedWeaponId === "number") {
weaponIdsToInclude.add(selectedWeaponId);
}
const quickSelectCategory = {
idx: 0,
key: "quick-select" as const,
name: t("common:forms.weaponSearch.quickSelect"),
items: items
.flatMap((c) =>
c.items
.map((item) => (item.weapon.type === "MAIN" ? item : null))
.filter((val) => val !== null),
)
.filter((item) =>
weaponIdsToInclude.has(item.weapon.id as MainWeaponId),
)
.sort((a, b) => {
const aIdx = quickSelectWeaponsIds.indexOf(
a.weapon.id as MainWeaponId,
);
const bIdx = quickSelectWeaponsIds.indexOf(
b.weapon.id as MainWeaponId,
);
return aIdx - bIdx;
}),
};
return {
// not too sure why we need to type cast here.. was working fine before refactoring
items: [quickSelectCategory] as typeof items,
filterValue,
setFilterValue,
};
}
return {
items,
filterValue,
setFilterValue,
};
}
const allWeaponCategoriesCache = new Map<
string,
ReturnType<typeof buildAllWeaponCategories>
>();
function useAllWeaponCategories(withSubSpecial = false) {
const { t, i18n } = useTranslation(["weapons"]);
const cacheKey = `${i18n.language}-${withSubSpecial}`;
const cached = allWeaponCategoriesCache.get(cacheKey);
if (cached) return cached;
const categories = buildAllWeaponCategories(t, withSubSpecial);
if (i18n.hasLoadedNamespace("weapons")) {
allWeaponCategoriesCache.set(cacheKey, categories);
}
return categories;
}
function buildAllWeaponCategories(
t: TFunction<["weapons"]>,
withSubSpecial: boolean,
) {
const mainWeaponCategories = weaponCategories.map((category, idx) => ({
name: category.name,
key: category.name,
idx,
items: category.weaponIds.map((id) => ({
name: t(`weapons:MAIN_${id}`),
weapon: {
anyWeaponId: `MAIN_${id}`,
id,
type: "MAIN" as const,
},
})),
}));
if (!withSubSpecial) {
return mainWeaponCategories;
}
const subWeaponCategory = {
name: "subs" as const,
key: "subs",
idx: 0,
items: subWeaponIds.map((id) => ({
name: t(`weapons:SUB_${id}`),
weapon: {
anyWeaponId: `SUB_${id}`,
id,
type: "SUB" as const,
},
})),
};
const specialWeaponCategory = {
name: "specials" as const,
key: "specials",
idx: 1,
items: specialWeaponIds
// currently no use-case exists to select big bubbler or tacticooler
.filter((id) => !nonDamagingSpecialWeaponIds.includes(id))
.map((id) => ({
name: t(`weapons:SPECIAL_${id}`),
weapon: {
anyWeaponId: `SPECIAL_${id}`,
id,
type: "SPECIAL" as const,
},
})),
};
return [
subWeaponCategory,
specialWeaponCategory,
...mainWeaponCategories.map((c) => ({ ...c, idx: c.idx + 2 })),
];
}
function keyify(value?: MainWeaponId | AnyWeapon | null) {
if (typeof value === "number") return `MAIN_${value}`;
if (!value) return value;
return `${value.type}_${value.id}`;
}
function collapseToSelectedItem<
Category extends { items: Array<{ weapon: { anyWeaponId: string } }> },
>(categories: Category[], selectedKey: string | null | undefined): Category[] {
// react-stately refuses to open a select whose collection is empty, so even
// with nothing selected the closed collection keeps one item around.
const fallbackItems = () => {
const firstCategory = categories[0];
if (!firstCategory) return [];
return [
{ ...firstCategory, items: firstCategory.items.slice(0, 1) } as Category,
];
};
if (!selectedKey) return fallbackItems();
for (const category of categories) {
const selectedItem = category.items.find(
(item) => item.weapon.anyWeaponId === selectedKey,
);
if (selectedItem) {
return [{ ...category, items: [selectedItem] } as Category];
}
}
return fallbackItems();
}

View File

@@ -0,0 +1,18 @@
.container {
position: relative;
width: 100%;
height: 0;
padding-bottom: 56.25%;
}
.containerApi {
width: fit-content;
}
.iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toastQueue } from "./elements/Toast";
import styles from "./YouTubeEmbed.module.css";
export function YouTubeEmbed({
id,
start,
autoplay = false,
enableApi = false,
onPlayerReady,
}: {
id: string;
start?: number;
autoplay?: boolean;
enableApi?: boolean;
onPlayerReady?: (player: YT.Player) => void;
}) {
const { t } = useTranslation(["vods"]);
const playerRef = useRef<YT.Player | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isApiReady, setIsApiReady] = useState(false);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (!enableApi || typeof window === "undefined") return;
if (window.YT?.Player) {
setIsApiReady(true);
return;
}
const tag = document.createElement("script");
tag.src = "https://www.youtube.com/iframe_api";
const firstScriptTag = document.getElementsByTagName("script")[0];
tag.onerror = () => {
toastQueue.add({
message: t("vods:errors.youtubePreviewFailed"),
variant: "error",
});
setHasError(true);
};
firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
window.onYouTubeIframeAPIReady = () => {
setIsApiReady(true);
};
const timeout = setTimeout(() => {
if (!window.YT?.Player) {
toastQueue.add({
message: t("vods:errors.youtubePreviewFailed"),
variant: "error",
});
setHasError(true);
}
}, 10000);
return () => clearTimeout(timeout);
}, [enableApi, t]);
useEffect(() => {
if (!enableApi || !isApiReady || !containerRef.current) return;
try {
const player = new window.YT.Player(containerRef.current, {
height: "281",
width: "500",
videoId: id,
playerVars: {
autoplay: autoplay ? 1 : 0,
controls: 1,
rel: 0,
modestbranding: 1,
start: start ?? 0,
},
events: {
onReady: () => {
playerRef.current = player;
onPlayerReady?.(player);
},
onError: () => {
toastQueue.add({
message: t("vods:errors.youtubePreviewFailed"),
variant: "error",
});
setHasError(true);
},
},
});
return () => {
player.destroy();
playerRef.current = null;
};
} catch {
toastQueue.add({
message: t("vods:errors.youtubePreviewFailed"),
variant: "error",
});
setHasError(true);
return;
}
}, [enableApi, isApiReady, id, start, autoplay, onPlayerReady, t]);
if (enableApi) {
if (hasError) {
return null;
}
return (
<div className={styles.containerApi}>
<div ref={containerRef} />
</div>
);
}
return (
<div className={styles.container}>
<iframe
className={styles.iframe}
src={`https://www.youtube.com/embed/${id}?autoplay=${
autoplay ? "1" : "0"
}&controls=1&rel=0&modestbranding=1&start=${start ?? 0}`}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title="Embedded youtube"
/>
</div>
);
}

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