mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
18
apps/web-react/app/browser-test-setup.ts
Normal file
18
apps/web-react/app/browser-test-setup.ts
Normal 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,
|
||||
});
|
||||
34
apps/web-react/app/components/AbilitiesSelector.module.css
Normal file
34
apps/web-react/app/components/AbilitiesSelector.module.css
Normal 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%);
|
||||
}
|
||||
}
|
||||
210
apps/web-react/app/components/AbilitiesSelector.tsx
Normal file
210
apps/web-react/app/components/AbilitiesSelector.tsx
Normal 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;
|
||||
}
|
||||
31
apps/web-react/app/components/Ability.module.css
Normal file
31
apps/web-react/app/components/Ability.module.css
Normal 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;
|
||||
}
|
||||
91
apps/web-react/app/components/Ability.tsx
Normal file
91
apps/web-react/app/components/Ability.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
apps/web-react/app/components/ActionButton.tsx
Normal file
113
apps/web-react/app/components/ActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web-react/app/components/Alert.module.css
Normal file
43
apps/web-react/app/components/Alert.module.css
Normal 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);
|
||||
}
|
||||
50
apps/web-react/app/components/Alert.tsx
Normal file
50
apps/web-react/app/components/Alert.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/web-react/app/components/Avatar.module.css
Normal file
12
apps/web-react/app/components/Avatar.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
168
apps/web-react/app/components/Avatar.tsx
Normal file
168
apps/web-react/app/components/Avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/web-react/app/components/Badge.tsx
Normal file
38
apps/web-react/app/components/Badge.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
120
apps/web-react/app/components/BuildCard.module.css
Normal file
120
apps/web-react/app/components/BuildCard.module.css
Normal 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;
|
||||
}
|
||||
381
apps/web-react/app/components/BuildCard.tsx
Normal file
381
apps/web-react/app/components/BuildCard.tsx
Normal 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"} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
185
apps/web-react/app/components/Catcher.tsx
Normal file
185
apps/web-react/app/components/Catcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
apps/web-react/app/components/Chart.module.css
Normal file
30
apps/web-react/app/components/Chart.module.css
Normal 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;
|
||||
}
|
||||
335
apps/web-react/app/components/Chart.tsx
Normal file
335
apps/web-react/app/components/Chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/web-react/app/components/CopyToClipboardPopover.tsx
Normal file
35
apps/web-react/app/components/CopyToClipboardPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
apps/web-react/app/components/CustomThemeSelector.module.css
Normal file
107
apps/web-react/app/components/CustomThemeSelector.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
507
apps/web-react/app/components/CustomThemeSelector.tsx
Normal file
507
apps/web-react/app/components/CustomThemeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/web-react/app/components/Divider.module.css
Normal file
28
apps/web-react/app/components/Divider.module.css
Normal 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);
|
||||
}
|
||||
22
apps/web-react/app/components/Divider.tsx
Normal file
22
apps/web-react/app/components/Divider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/web-react/app/components/DotPagination.module.css
Normal file
27
apps/web-react/app/components/DotPagination.module.css
Normal 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);
|
||||
}
|
||||
38
apps/web-react/app/components/DotPagination.tsx
Normal file
38
apps/web-react/app/components/DotPagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/web-react/app/components/EventsList.module.css
Normal file
8
apps/web-react/app/components/EventsList.module.css
Normal 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;
|
||||
}
|
||||
110
apps/web-react/app/components/EventsList.tsx
Normal file
110
apps/web-react/app/components/EventsList.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
apps/web-react/app/components/Flag.tsx
Normal file
26
apps/web-react/app/components/Flag.tsx
Normal 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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
7
apps/web-react/app/components/FormErrors.module.css
Normal file
7
apps/web-react/app/components/FormErrors.module.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.container {
|
||||
font-size: var(--font-sm);
|
||||
|
||||
& > h4 {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
24
apps/web-react/app/components/FormErrors.tsx
Normal file
24
apps/web-react/app/components/FormErrors.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/web-react/app/components/FormMessage.module.css
Normal file
17
apps/web-react/app/components/FormMessage.module.css
Normal 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;
|
||||
}
|
||||
30
apps/web-react/app/components/FormMessage.tsx
Normal file
30
apps/web-react/app/components/FormMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
apps/web-react/app/components/FormWithConfirm.tsx
Normal file
139
apps/web-react/app/components/FormWithConfirm.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
apps/web-react/app/components/FriendCodeInput.tsx
Normal file
50
apps/web-react/app/components/FriendCodeInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
apps/web-react/app/components/FriendCodePopover.tsx
Normal file
33
apps/web-react/app/components/FriendCodePopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/web-react/app/components/GameTimeline.module.css
Normal file
120
apps/web-react/app/components/GameTimeline.module.css
Normal 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;
|
||||
}
|
||||
377
apps/web-react/app/components/GameTimeline.tsx
Normal file
377
apps/web-react/app/components/GameTimeline.tsx
Normal 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")],
|
||||
};
|
||||
}
|
||||
115
apps/web-react/app/components/GearSelect.tsx
Normal file
115
apps/web-react/app/components/GearSelect.tsx
Normal 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;
|
||||
}
|
||||
8
apps/web-react/app/components/Image.module.css
Normal file
8
apps/web-react/app/components/Image.module.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.tierContainer {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.tierImg {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
243
apps/web-react/app/components/Image.tsx
Normal file
243
apps/web-react/app/components/Image.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/web-react/app/components/InfoPopover.module.css
Normal file
24
apps/web-react/app/components/InfoPopover.module.css
Normal 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);
|
||||
}
|
||||
30
apps/web-react/app/components/InfoPopover.tsx
Normal file
30
apps/web-react/app/components/InfoPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
apps/web-react/app/components/IngameNameInput.module.css
Normal file
59
apps/web-react/app/components/IngameNameInput.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
165
apps/web-react/app/components/IngameNameInput.tsx
Normal file
165
apps/web-react/app/components/IngameNameInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/web-react/app/components/Input.module.css
Normal file
61
apps/web-react/app/components/Input.module.css
Normal 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;
|
||||
}
|
||||
86
apps/web-react/app/components/Input.tsx
Normal file
86
apps/web-react/app/components/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/web-react/app/components/Label.module.css
Normal file
29
apps/web-react/app/components/Label.module.css
Normal 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);
|
||||
}
|
||||
49
apps/web-react/app/components/Label.tsx
Normal file
49
apps/web-react/app/components/Label.tsx
Normal 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;
|
||||
}
|
||||
56
apps/web-react/app/components/LocaleTime.tsx
Normal file
56
apps/web-react/app/components/LocaleTime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/web-react/app/components/LocaleTimeRange.tsx
Normal file
56
apps/web-react/app/components/LocaleTimeRange.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/web-react/app/components/Main.module.css
Normal file
31
apps/web-react/app/components/Main.module.css
Normal 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;
|
||||
}
|
||||
62
apps/web-react/app/components/Main.tsx
Normal file
62
apps/web-react/app/components/Main.tsx
Normal 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;
|
||||
};
|
||||
74
apps/web-react/app/components/MapPoolSelector.module.css
Normal file
74
apps/web-react/app/components/MapPoolSelector.module.css
Normal 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%);
|
||||
}
|
||||
373
apps/web-react/app/components/MapPoolSelector.tsx
Normal file
373
apps/web-react/app/components/MapPoolSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web-react/app/components/Markdown.tsx
Normal file
43
apps/web-react/app/components/Markdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
390
apps/web-react/app/components/MobileNav.module.css
Normal file
390
apps/web-react/app/components/MobileNav.module.css
Normal 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;
|
||||
}
|
||||
662
apps/web-react/app/components/MobileNav.tsx
Normal file
662
apps/web-react/app/components/MobileNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/web-react/app/components/NoteAvatar.module.css
Normal file
73
apps/web-react/app/components/NoteAvatar.module.css
Normal 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);
|
||||
}
|
||||
75
apps/web-react/app/components/NoteAvatar.tsx
Normal file
75
apps/web-react/app/components/NoteAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
apps/web-react/app/components/NotificationDot.module.css
Normal file
42
apps/web-react/app/components/NotificationDot.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
17
apps/web-react/app/components/NotificationDot.tsx
Normal file
17
apps/web-react/app/components/NotificationDot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
290
apps/web-react/app/components/ObjectiveTimeline.tsx
Normal file
290
apps/web-react/app/components/ObjectiveTimeline.tsx
Normal 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}`;
|
||||
}
|
||||
117
apps/web-react/app/components/Pagination.module.css
Normal file
117
apps/web-react/app/components/Pagination.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
140
apps/web-react/app/components/Pagination.test.ts
Normal file
140
apps/web-react/app/components/Pagination.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
277
apps/web-react/app/components/Pagination.tsx
Normal file
277
apps/web-react/app/components/Pagination.tsx
Normal 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 };
|
||||
}
|
||||
3
apps/web-react/app/components/Placeholder.module.css
Normal file
3
apps/web-react/app/components/Placeholder.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.placeholder {
|
||||
height: 100vh;
|
||||
}
|
||||
6
apps/web-react/app/components/Placeholder.tsx
Normal file
6
apps/web-react/app/components/Placeholder.tsx
Normal 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} />;
|
||||
}
|
||||
75
apps/web-react/app/components/Placement.tsx
Normal file
75
apps/web-react/app/components/Placement.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
182
apps/web-react/app/components/PlayerStatusTimeline.tsx
Normal file
182
apps/web-react/app/components/PlayerStatusTimeline.tsx
Normal 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;
|
||||
}
|
||||
12
apps/web-react/app/components/Redirect.tsx
Normal file
12
apps/web-react/app/components/Redirect.tsx
Normal 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;
|
||||
}
|
||||
48
apps/web-react/app/components/RelativeTime.browser.test.tsx
Normal file
48
apps/web-react/app/components/RelativeTime.browser.test.tsx
Normal 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));
|
||||
});
|
||||
});
|
||||
24
apps/web-react/app/components/RelativeTime.tsx
Normal file
24
apps/web-react/app/components/RelativeTime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/web-react/app/components/Section.module.css
Normal file
12
apps/web-react/app/components/Section.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
18
apps/web-react/app/components/Section.tsx
Normal file
18
apps/web-react/app/components/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
apps/web-react/app/components/ShareUrlButton.tsx
Normal file
44
apps/web-react/app/components/ShareUrlButton.tsx
Normal 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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
308
apps/web-react/app/components/SideNav.module.css
Normal file
308
apps/web-react/app/components/SideNav.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
220
apps/web-react/app/components/SideNav.tsx
Normal file
220
apps/web-react/app/components/SideNav.tsx
Normal 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>;
|
||||
}
|
||||
26
apps/web-react/app/components/SortableTableHeader.module.css
Normal file
26
apps/web-react/app/components/SortableTableHeader.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
57
apps/web-react/app/components/SortableTableHeader.tsx
Normal file
57
apps/web-react/app/components/SortableTableHeader.tsx
Normal 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;
|
||||
}
|
||||
17
apps/web-react/app/components/StageBannerBox.module.css
Normal file
17
apps/web-react/app/components/StageBannerBox.module.css
Normal 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;
|
||||
}
|
||||
33
apps/web-react/app/components/StageBannerBox.tsx
Normal file
33
apps/web-react/app/components/StageBannerBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/web-react/app/components/StageSelect.module.css
Normal file
16
apps/web-react/app/components/StageSelect.module.css
Normal 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;
|
||||
}
|
||||
89
apps/web-react/app/components/StageSelect.tsx
Normal file
89
apps/web-react/app/components/StageSelect.tsx
Normal 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}`),
|
||||
}));
|
||||
}
|
||||
58
apps/web-react/app/components/StreamListItems.module.css
Normal file
58
apps/web-react/app/components/StreamListItems.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
190
apps/web-react/app/components/StreamListItems.tsx
Normal file
190
apps/web-react/app/components/StreamListItems.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
apps/web-react/app/components/SubNav.module.css
Normal file
90
apps/web-react/app/components/SubNav.module.css
Normal 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;
|
||||
}
|
||||
69
apps/web-react/app/components/SubNav.tsx
Normal file
69
apps/web-react/app/components/SubNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/web-react/app/components/SubmitButton.tsx
Normal file
58
apps/web-react/app/components/SubmitButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/web-react/app/components/Table.module.css
Normal file
39
apps/web-react/app/components/Table.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
20
apps/web-react/app/components/Table.tsx
Normal file
20
apps/web-react/app/components/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
apps/web-react/app/components/TierPill.module.css
Normal file
131
apps/web-react/app/components/TierPill.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
50
apps/web-react/app/components/TierPill.tsx
Normal file
50
apps/web-react/app/components/TierPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
apps/web-react/app/components/TimePopover.module.css
Normal file
14
apps/web-react/app/components/TimePopover.module.css
Normal 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;
|
||||
}
|
||||
91
apps/web-react/app/components/TimePopover.tsx
Normal file
91
apps/web-react/app/components/TimePopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web-react/app/components/UserLink.module.css
Normal file
10
apps/web-react/app/components/UserLink.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
53
apps/web-react/app/components/UserLink.tsx
Normal file
53
apps/web-react/app/components/UserLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
apps/web-react/app/components/WeaponSelect.module.css
Normal file
16
apps/web-react/app/components/WeaponSelect.module.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.item {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.weaponImg {
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.weaponLabel {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
426
apps/web-react/app/components/WeaponSelect.tsx
Normal file
426
apps/web-react/app/components/WeaponSelect.tsx
Normal 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();
|
||||
}
|
||||
18
apps/web-react/app/components/YouTubeEmbed.module.css
Normal file
18
apps/web-react/app/components/YouTubeEmbed.module.css
Normal 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%;
|
||||
}
|
||||
134
apps/web-react/app/components/YouTubeEmbed.tsx
Normal file
134
apps/web-react/app/components/YouTubeEmbed.tsx
Normal 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
Reference in New Issue
Block a user