Migrate Comboboxes to react-aria-components (#2408)

* Initial

* wip

* AnyWeapon

* del

* wip

* import stuff

* gearselect

* brand images

* wip

* wip

* art

* Remove old

* Fix tournament map pool link

* Simplify GearSelect

* convert to todo
This commit is contained in:
Kalle 2025-06-21 13:44:26 +03:00 committed by GitHub
parent 4e6951e36b
commit 7ed1394fba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
178 changed files with 1686 additions and 1547 deletions

View File

@ -1,452 +0,0 @@
import { Combobox as HeadlessCombobox } from "@headlessui/react";
import clsx from "clsx";
import Fuse, { type IFuseOptions } from "fuse.js";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { GearType } from "~/db/tables";
import type { SerializedMapPoolEvent } from "~/features/calendar/routes/map-pool-events";
import { useAllEventsWithMapPools } from "~/hooks/swr";
import {
clothesGearIds,
headGearIds,
shoesGearIds,
} from "~/modules/in-game-lists/gear-ids";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { weaponAltNames } from "~/modules/in-game-lists/weapon-alt-names";
import {
mainWeaponIds,
subWeaponIds,
weaponCategories,
} from "~/modules/in-game-lists/weapon-ids";
import {
nonBombSubWeaponIds,
nonDamagingSpecialWeaponIds,
specialWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import type { Unpacked } from "~/utils/types";
import {
gearImageUrl,
mainWeaponImageUrl,
specialWeaponImageUrl,
subWeaponImageUrl,
} from "~/utils/urls";
import { Image } from "./Image";
const MAX_RESULTS_SHOWN = 6;
interface ComboboxBaseOption {
label: string;
/** Alternative text other than label to match by */
alt?: string[];
value: string;
imgPath?: string;
}
type ComboboxOption<T> = ComboboxBaseOption & T;
interface ComboboxProps<T> {
options: ComboboxOption<T>[];
quickSelectOptions?: ComboboxOption<T>[];
inputName: string;
placeholder: string;
className?: string;
id?: string;
isLoading?: boolean;
required?: boolean;
value?: ComboboxOption<T> | null;
initialValue: ComboboxOption<T> | null;
onChange?: (selectedOption: ComboboxOption<T> | null) => void;
fullWidth?: boolean;
nullable?: true;
fuseOptions?: IFuseOptions<ComboboxOption<T>>;
}
export function Combobox<
T extends Record<string, string | string[] | null | undefined | number>,
>({
options,
quickSelectOptions,
inputName,
placeholder,
value,
initialValue,
onChange,
required,
className,
id,
nullable,
isLoading = false,
fullWidth = false,
fuseOptions = {},
}: ComboboxProps<T>) {
const { t } = useTranslation();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const [_selectedOption, setSelectedOption] = React.useState<Unpacked<
typeof options
> | null>(initialValue);
const [query, setQuery] = React.useState("");
const fuse = new Fuse(options, {
...fuseOptions,
keys: ["label", "alt"],
});
const filteredOptions = (() => {
if (!query) {
if (quickSelectOptions) return quickSelectOptions;
return [];
}
return fuse
.search(query)
.slice(0, MAX_RESULTS_SHOWN)
.map((res) => res.item);
})();
const noMatches = filteredOptions.length === 0;
const displayValue = (option: Unpacked<typeof options>) => {
return option?.label ?? "";
};
const selectedOption = value ?? _selectedOption;
const showComboboxOptions = () => {
if (!quickSelectOptions || quickSelectOptions.length === 0) return;
buttonRef.current?.click();
};
return (
<div className="combobox-wrapper">
<HeadlessCombobox
value={selectedOption}
onChange={(selected) => {
onChange?.(selected);
setSelectedOption(selected);
// https://github.com/tailwindlabs/headlessui/issues/1555
// note that this still seems to be a problem despite what the issue says
setTimeout(() => inputRef.current?.blur(), 0);
}}
name={inputName}
disabled={!selectedOption && isLoading}
// TODO: remove hack that prevents TS from freaking out. probably related: https://github.com/tailwindlabs/headlessui/issues/1895
nullable={nullable as true}
>
<HeadlessCombobox.Input
onChange={(event) => setQuery(event.target.value)}
placeholder={isLoading ? t("actions.loading") : placeholder}
className={clsx("combobox-input", className, {
fullWidth,
})}
defaultValue={initialValue}
displayValue={displayValue}
data-testid={`${inputName}-combobox-input`}
id={id}
required={required}
autoComplete="off"
onFocus={showComboboxOptions}
ref={inputRef}
/>
<HeadlessCombobox.Options
className={clsx("combobox-options", {
empty: noMatches,
fullWidth,
hidden:
!query &&
(!quickSelectOptions || quickSelectOptions.length === 0),
})}
>
{isLoading ? (
<div className="combobox-no-matches">{t("actions.loading")}</div>
) : noMatches ? (
<div className="combobox-no-matches">
{t("forms.errors.noSearchMatches")}{" "}
<span className="combobox-emoji">🤔</span>
</div>
) : (
filteredOptions.map((option) => (
<HeadlessCombobox.Option
key={option.value}
value={option}
as={React.Fragment}
>
{({ active }) => (
<li className={clsx("combobox-item", { active })}>
{option.imgPath && (
<Image
alt=""
path={option.imgPath}
width={24}
height={24}
className="combobox-item-image"
/>
)}
<span className="combobox-item-label">{option.label}</span>
</li>
)}
</HeadlessCombobox.Option>
))
)}
</HeadlessCombobox.Options>
<HeadlessCombobox.Button ref={buttonRef} className="hidden" />
</HeadlessCombobox>
</div>
);
}
export function WeaponCombobox({
id,
required,
className,
inputName,
onChange,
initialWeaponId,
weaponIdsToOmit,
fullWidth,
nullable,
value,
quickSelectWeaponIds,
}: Pick<
ComboboxProps<ComboboxBaseOption>,
| "inputName"
| "onChange"
| "className"
| "id"
| "required"
| "fullWidth"
| "nullable"
> & {
initialWeaponId?: (typeof mainWeaponIds)[number];
weaponIdsToOmit?: Set<MainWeaponId>;
value?: MainWeaponId | null;
/** Weapons to show when there is focus but no query */
quickSelectWeaponIds?: MainWeaponId[];
}) {
const { t, i18n } = useTranslation("weapons");
const alt = (id: (typeof mainWeaponIds)[number]) => {
const result: string[] = [];
if (i18n.language !== "en") {
result.push(t(`MAIN_${id}`, { lng: "en" }));
}
const altNames = weaponAltNames.get(id);
if (typeof altNames === "string") {
result.push(altNames);
} else if (Array.isArray(altNames)) {
result.push(...altNames);
}
return result;
};
const idToWeapon = (id: (typeof mainWeaponIds)[number]) => ({
value: String(id),
label: t(`MAIN_${id}`),
imgPath: mainWeaponImageUrl(id),
alt: alt(id),
});
const options = mainWeaponIds
.filter((id) => !weaponIdsToOmit?.has(id))
.map(idToWeapon);
const quickSelectOptions = quickSelectWeaponIds?.flatMap((weaponId) => {
return options.find((option) => option.value === String(weaponId)) ?? [];
});
return (
<Combobox
inputName={inputName}
options={options}
quickSelectOptions={quickSelectOptions}
value={typeof value === "number" ? idToWeapon(value) : null}
initialValue={
typeof initialWeaponId === "number" ? idToWeapon(initialWeaponId) : null
}
placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)}
onChange={onChange}
className={className}
id={id}
required={required}
fullWidth={fullWidth}
nullable={nullable}
/>
);
}
export function AllWeaponCombobox({
id,
inputName,
onChange,
fullWidth,
}: Pick<
ComboboxProps<ComboboxBaseOption>,
"inputName" | "onChange" | "id" | "fullWidth"
>) {
const { t } = useTranslation("weapons");
const options = () => {
const result: ComboboxProps<
Record<string, string | null | number>
>["options"] = [];
for (const mainWeaponId of mainWeaponIds) {
result.push({
value: `MAIN_${mainWeaponId}`,
label: t(`MAIN_${mainWeaponId}`),
imgPath: mainWeaponImageUrl(mainWeaponId),
});
}
for (const subWeaponId of subWeaponIds) {
if (nonBombSubWeaponIds.includes(subWeaponId)) continue;
result.push({
value: `SUB_${subWeaponId}`,
label: t(`SUB_${subWeaponId}`),
imgPath: subWeaponImageUrl(subWeaponId),
});
}
for (const specialWeaponId of specialWeaponIds) {
if (nonDamagingSpecialWeaponIds.includes(specialWeaponId)) continue;
result.push({
value: `SPECIAL_${specialWeaponId}`,
label: t(`SPECIAL_${specialWeaponId}`),
imgPath: specialWeaponImageUrl(specialWeaponId),
});
}
return result;
};
return (
<Combobox
inputName={inputName}
options={options()}
initialValue={null}
placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)}
onChange={onChange}
id={id}
fullWidth={fullWidth}
/>
);
}
export function GearCombobox({
id,
required,
className,
inputName,
onChange,
gearType,
initialGearId,
nullable,
}: Pick<
ComboboxProps<ComboboxBaseOption>,
"inputName" | "onChange" | "className" | "id" | "required" | "nullable"
> & { gearType: GearType; initialGearId?: number }) {
const { t } = useTranslation("gear");
const translationPrefix =
gearType === "HEAD" ? "H" : gearType === "CLOTHES" ? "C" : "S";
const ids =
gearType === "HEAD"
? headGearIds
: gearType === "CLOTHES"
? clothesGearIds
: shoesGearIds;
const idToGear = (id: (typeof ids)[number]) => ({
value: String(id),
label: t(`${translationPrefix}_${id}` as any),
imgPath: gearImageUrl(gearType, id),
});
return (
<Combobox
inputName={inputName}
options={ids.map(idToGear)}
placeholder={idToGear(ids[0]).label}
initialValue={initialGearId ? idToGear(initialGearId as any) : null}
onChange={onChange}
className={className}
id={id}
required={required}
nullable={nullable}
/>
);
}
const mapPoolEventToOption = (
e: SerializedMapPoolEvent,
): ComboboxOption<Pick<SerializedMapPoolEvent, "serializedMapPool">> => ({
serializedMapPool: e.serializedMapPool,
label: e.name,
value: e.id.toString(),
});
type MapPoolEventsComboboxProps = Pick<
ComboboxProps<Pick<SerializedMapPoolEvent, "serializedMapPool">>,
"inputName" | "className" | "id" | "required"
> & {
initialEvent?: SerializedMapPoolEvent;
onChange: (event: SerializedMapPoolEvent | null) => void;
};
export function MapPoolEventsCombobox({
id,
required,
className,
inputName,
onChange,
initialEvent,
}: MapPoolEventsComboboxProps) {
const { t } = useTranslation();
const { events, isLoading, isError } = useAllEventsWithMapPools();
const options = React.useMemo(
() => (events ? events.map(mapPoolEventToOption) : []),
[events],
);
// this is important so that we don't trigger the reset to the initialEvent every time
const initialOption = React.useMemo(
() => initialEvent && mapPoolEventToOption(initialEvent),
[initialEvent],
);
if (isError) {
return (
<div className="text-sm text-error">{t("errors.genericReload")}</div>
);
}
return (
<Combobox
inputName={inputName}
options={isLoading && initialOption ? [initialOption] : options}
placeholder={t("actions.search")}
initialValue={initialOption ?? null}
onChange={(e) => {
onChange(
e && {
id: Number.parseInt(e.value, 10),
name: e.label,
serializedMapPool: e.serializedMapPool,
},
);
}}
className={className}
id={id}
required={required}
isLoading={isLoading}
fullWidth
/>
);
}

View File

@ -0,0 +1,140 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import {
SendouSelect,
SendouSelectItem,
SendouSelectItemSection,
} from "~/components/elements/Select";
import type { GearType } from "~/db/tables";
import { brandIds } from "~/modules/in-game-lists/brand-ids";
import {
clothesGearBrandGrouped,
headGearBrandGrouped,
shoesGearBrandGrouped,
} from "~/modules/in-game-lists/gear-ids";
import type { BrandId } 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"),
}}
className={styles.selectWidthWider}
popoverClassName={styles.selectWidthWider}
selectedKey={value}
defaultSelectedKey={initialValue}
onSelectionChange={(value) => onChange?.(value as any)}
clearable={clearable}
data-testid={`${type}-gear-select`}
>
{({ key, items: gear, brandId, idx }) => (
<SendouSelectItemSection
heading={
<CategoryHeading
brandId={brandId}
className={idx === 0 ? "pt-0-5-forced" : undefined}
/>
}
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 CategoryHeading({
className,
brandId,
}: {
className?: string;
brandId: BrandId;
}) {
const { t } = useTranslation(["game-misc"]);
return (
<div className={clsx(className, styles.categoryHeading)}>
<Image path={brandImageUrl(brandId)} size={28} alt="" />
{t(`game-misc:BRAND_${brandId}` as any)}
<div className={styles.categoryDivider} />
</div>
);
}
function useGearItems(type: GearType) {
const { t } = useTranslation(["gear", "game-misc"]);
const translationPrefix =
type === "HEAD" ? "H" : type === "CLOTHES" ? "C" : "S";
const groupedGear =
type === "HEAD"
? headGearBrandGrouped
: type === "CLOTHES"
? clothesGearBrandGrouped
: shoesGearBrandGrouped;
const items = brandIds.map((brandId, idx) => {
const items = groupedGear[brandId] || [];
return {
brandId,
key: brandId,
idx,
items: items.map((gearId) => ({
id: gearId,
name: t(`${translationPrefix}_${gearId}` as any),
})),
};
});
return items;
}

View File

@ -3,7 +3,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import type { Tables } from "~/db/tables";
import type { SerializedMapPoolEvent } from "~/features/calendar/routes/map-pool-events";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import { modesShort } from "~/modules/in-game-lists/modes";
@ -12,7 +11,6 @@ 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 { MapPoolEventsCombobox } from "./Combobox";
import { SendouButton } from "./elements/Button";
import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft";
import { CrossIcon } from "./icons/Cross";
@ -28,8 +26,6 @@ export type MapPoolSelectorProps = {
event?: Pick<Tables["CalendarEvent"], "id" | "name">,
) => void;
className?: string;
recentEvents?: SerializedMapPoolEvent[];
initialEvent?: Pick<Tables["CalendarEvent"], "id" | "name">;
title?: string;
modesToInclude?: ModeShort[];
info?: React.ReactNode;
@ -45,8 +41,6 @@ export function MapPoolSelector({
handleMapPoolChange,
handleRemoval,
className,
recentEvents,
initialEvent,
title,
modesToInclude,
info,
@ -57,15 +51,7 @@ export function MapPoolSelector({
const { t } = useTranslation();
const [template, setTemplate] = React.useState<MapPoolTemplateValue>(
initialEvent ? "event" : detectTemplate(mapPool),
);
const [initialSerializedEvent, setInitialSerializedEvent] = React.useState(
(): SerializedMapPoolEvent | undefined =>
initialEvent && {
...initialEvent,
serializedMapPool: mapPool.serialized,
},
detectTemplate(mapPool),
);
const handleStageModesChange = (newMapPool: MapPool) => {
@ -85,14 +71,6 @@ export function MapPoolSelector({
return;
}
if (template === "event") {
// If the user selected the "event" option, the _initial_ event passed via
// props is likely not the current state and should not be prefilled
// anymore.
setInitialSerializedEvent(undefined);
return;
}
if (startsWith(template, "preset:")) {
const [, presetId] = split(template, ":");
@ -100,17 +78,6 @@ export function MapPoolSelector({
return;
}
if (startsWith(template, "recent-event:")) {
const [, eventId] = split(template, ":");
const event = recentEvents?.find((e) => e.id.toString() === eventId);
if (event) {
handleMapPoolChange(new MapPool(event.serializedMapPool), event);
}
return;
}
assertType<never, typeof template>();
};
@ -141,14 +108,7 @@ export function MapPoolSelector({
<MapPoolTemplateSelect
value={template}
handleChange={handleTemplateChange}
recentEvents={recentEvents}
/>
{template === "event" && (
<TemplateEventSelection
initialEvent={initialSerializedEvent}
handleEventChange={handleMapPoolChange}
/>
)}
</div>
)}
{info}
@ -355,11 +315,7 @@ type MapModePresetId = "ANARCHY" | "ALL" | ModeShort;
const presetIds: MapModePresetId[] = ["ANARCHY", "ALL", ...modesShort];
type MapPoolTemplateValue =
| "none"
| `preset:${MapModePresetId}`
| `recent-event:${string}`
| "event";
type MapPoolTemplateValue = "none" | `preset:${MapModePresetId}`;
function detectTemplate(mapPool: MapPool): MapPoolTemplateValue {
for (const presetId of presetIds) {
@ -393,7 +349,6 @@ function MapPoolTemplateSelect({
}}
>
<option value="none">{t("common:maps.template.none")}</option>
<option value="event">{t("common:maps.template.event")}</option>
<optgroup label={t("common:maps.template.presets")}>
{(["ANARCHY", "ALL"] as const).map((presetId) => (
<option key={presetId} value={`preset:${presetId}`}>
@ -421,37 +376,3 @@ function MapPoolTemplateSelect({
</label>
);
}
type TemplateEventSelectionProps = {
handleEventChange: (
mapPool: MapPool,
event?: Pick<Tables["CalendarEvent"], "id" | "name">,
) => void;
initialEvent?: SerializedMapPoolEvent;
};
function TemplateEventSelection({
handleEventChange,
initialEvent,
}: TemplateEventSelectionProps) {
const { t } = useTranslation();
const id = React.useId();
return (
<label className="stack sm">
{t("maps.template.event")}
<MapPoolEventsCombobox
id={id}
inputName={id}
onChange={(e) => {
if (e) {
handleEventChange(new MapPool(e.serializedMapPool), {
id: e.id,
name: e.name,
});
}
}}
initialEvent={initialEvent}
/>
</label>
);
}

View File

@ -0,0 +1,48 @@
.selectWidthWider {
--select-width: 250px;
}
.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;
}
.categoryHeading {
display: flex;
align-items: center;
gap: var(--s-2);
font-weight: bold;
color: var(--text-lighter);
text-transform: uppercase;
font-size: var(--fonts-xxs);
padding-block-start: var(--s-2-5);
padding-block-end: var(--s-1);
padding-inline: var(--s-1-5);
white-space: nowrap;
}
.categoryDivider {
background-color: var(--border);
width: 100%;
height: 2px;
margin-block: var(--s-2);
}
.categoryHeading img {
border-radius: 100%;
background-color: var(--bg-lightest);
padding: var(--s-1);
min-width: 28px;
}

View File

@ -0,0 +1,280 @@
import clsx from "clsx";
import * as React from "react";
import type { Key } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Image, WeaponImage } from "~/components/Image";
import {
SendouSelect,
SendouSelectItem,
SendouSelectItemSection,
} from "~/components/elements/Select";
import type { AnyWeapon } from "~/features/build-analyzer";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { filterWeapon } from "~/modules/in-game-lists/utils";
import {
SPLAT_BOMB_ID,
TRIZOOKA_ID,
specialWeaponIds,
subWeaponIds,
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)
| (Clearable extends true ? null : never);
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>; // TODO: implement for `AnyWeapon` if needed
testId?: string;
isRequired?: boolean;
}
// TODO: fix selected value disappears when filtered out. This is because `items` is filtered in a controlled manner and the selected key might not be included in the filtered items.
export function WeaponSelect<
Clearable extends boolean | undefined = undefined,
IncludeSubSpecial extends boolean | undefined = undefined,
>({
label,
value,
initialValue,
onChange,
disabledWeaponIds,
clearable,
includeSubSpecial,
testId = "weapon-select",
isRequired,
}: WeaponSelectProps<Clearable, IncludeSubSpecial>) {
const { t } = useTranslation(["common"]);
const { items, filterValue, setFilterValue } =
useFilteredWeaponItems(includeSubSpecial);
const keyify = (value?: MainWeaponId | AnyWeapon | null) => {
if (typeof value === "number") return `MAIN_${value}`;
if (!value) return;
return `${value.type}_${value.id}`;
};
const handleOnChange = (key: Key | null) => {
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
}
items={items}
label={label}
placeholder={t("common:forms.weaponSearch.placeholder")}
search={{
placeholder: t("common:forms.weaponSearch.search.placeholder"),
}}
className={styles.selectWidthWider}
popoverClassName={styles.selectWidthWider}
searchInputValue={filterValue}
onSearchInputChange={setFilterValue}
selectedKey={keyify(value)}
defaultSelectedKey={keyify(initialValue)}
onSelectionChange={handleOnChange}
clearable={clearable}
data-testid={testId}
isRequired={isRequired}
>
{({ key, items: weapons, name, idx }) => (
<SendouSelectItemSection
heading={
<CategoryHeading
name={name}
className={idx === 0 ? "pt-0-5-forced" : 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>
);
}
function CategoryHeading({
name,
className,
}: {
name: (typeof weaponCategories)[number]["name"] | "subs" | "specials";
className?: string;
}) {
const { t } = useTranslation(["common"]);
const path = () => {
if (name === "subs") return subWeaponImageUrl(SPLAT_BOMB_ID);
if (name === "specials") return specialWeaponImageUrl(TRIZOOKA_ID);
return weaponCategoryUrl(name);
};
return (
<div className={clsx(className, styles.categoryHeading)}>
<Image path={path()} size={28} alt="" />
{t(`common:weapon.category.${name}`)}
<div className={styles.categoryDivider} />
</div>
);
}
function useFilteredWeaponItems(includeSubSpecial: boolean | undefined) {
const items = useAllWeaponCategories(includeSubSpecial);
const [filterValue, setFilterValue] = React.useState("");
const filtered = !filterValue
? items
: items
.map((category) => {
const filteredItems = category.items.filter((item) =>
filterWeapon({
weapon: item.weapon,
weaponName: item.name,
searchTerm: filterValue,
}),
);
return {
...category,
items: filteredItems,
};
})
.filter((category) => category.items.length > 0)
.map((category, idx) => ({ ...category, idx }));
return {
items: filtered,
filterValue,
setFilterValue,
};
}
function useAllWeaponCategories(withSubSpecial = false) {
const { t } = useTranslation(["weapons"]);
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.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 })),
];
}

View File

@ -78,6 +78,11 @@
overflow: hidden;
}
.item[data-disabled] {
color: var(--text-lighter);
font-style: italic;
}
.itemFocused {
background-color: var(--theme-transparent);
color: var(--text);
@ -134,3 +139,13 @@
padding-block: var(--s-8);
color: var(--text-lighter);
}
.select {
position: relative;
}
.clearButton {
position: absolute;
bottom: -17px;
right: 9px;
}

View File

@ -1,28 +1,33 @@
import clsx from "clsx";
import * as React from "react";
import type { ListBoxItemProps, SelectProps } from "react-aria-components";
import {
Autocomplete,
Button,
Header,
Input,
Label,
ListBox,
ListBoxItem,
ListBoxSection,
ListLayout,
Popover,
SearchField,
Select,
SelectStateContext,
SelectValue,
Virtualizer,
useFilter,
} from "react-aria-components";
import { useTranslation } from "react-i18next";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouButton } from "~/components/elements/Button";
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
import { CrossIcon } from "../icons/Cross";
import { SearchIcon } from "../icons/Search";
import styles from "./Select.module.css";
interface SendouSelectProps<T extends object>
export interface SendouSelectProps<T extends object>
extends Omit<SelectProps<T>, "children"> {
label?: string;
description?: string;
@ -33,8 +38,28 @@ interface SendouSelectProps<T extends object>
search?: {
placeholder?: string;
};
popoverClassName?: string;
/** Value of the search input, used for controlled components */
searchInputValue?: string;
/** Callback for when the search input value changes. When defined `items` has to be filtered on the caller side (automatic filtering in component disabled). */
onSearchInputChange?: (value: string) => void;
clearable?: boolean;
}
/**
* A customizable select component with optional search functionality. Virtualizes the list of items for performance.
*
* @example
* ```tsx
* <SendouSelect items={items} search={{ placeholder: "Search for items..." }}>
* {({ key, ...item }) => (
* <SendouSelectItem key={key} {...item}>
* {item.name}
* </SendouSelectItem>
* )}
* </SendouSelect>
* ```
*/
export function SendouSelect<T extends object>({
label,
description,
@ -43,13 +68,32 @@ export function SendouSelect<T extends object>({
children,
items,
search,
popoverClassName,
searchInputValue,
onSearchInputChange,
clearable = false,
className,
...props
}: SendouSelectProps<T>) {
const { t } = useTranslation(["common"]);
const { contains } = useFilter({ sensitivity: "base" });
const isControlled = !!onSearchInputChange;
const handleOpenChange = (isOpen: boolean) => {
if (!isControlled) return;
if (!isOpen) {
onSearchInputChange("");
}
};
return (
<Select {...props}>
<Select
{...props}
className={clsx(className, styles.select)}
onOpenChange={handleOpenChange}
>
{label ? <Label>{label}</Label> : null}
<Button className={styles.button}>
<SelectValue className={styles.selectValue} />
@ -57,9 +101,14 @@ export function SendouSelect<T extends object>({
<ChevronUpDownIcon className={styles.icon} />
</span>
</Button>
{clearable ? <SelectClearButton /> : null}
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<Popover className={styles.popover}>
<Autocomplete filter={contains}>
<Popover className={clsx(popoverClassName, styles.popover)}>
<Autocomplete
filter={isControlled ? undefined : contains}
inputValue={searchInputValue}
onInputChange={onSearchInputChange}
>
{search ? (
<SearchField
aria-label="Search"
@ -108,3 +157,40 @@ export function SendouSelectItem(props: SendouSelectItemProps) {
/>
);
}
interface SendouSelectItemSectionProps {
heading: React.ReactNode;
children: React.ReactNode;
}
export function SendouSelectItemSection({
heading,
children,
}: SendouSelectItemSectionProps) {
return (
<ListBoxSection>
<Header>{heading}</Header>
{children}
</ListBoxSection>
);
}
function SelectClearButton() {
const state = React.useContext(SelectStateContext);
if (!state?.selectedKey) return null;
return (
<SendouButton
// Don't inherit behavior from Select.
slot={null}
variant="minimal-destructive"
size="miniscule"
icon={<CrossIcon />}
onPress={() => state?.setSelectedKey(null)}
className={styles.clearButton}
>
Clear
</SendouButton>
);
}

View File

@ -69,6 +69,7 @@ export const UserSearch = React.forwardRef(function UserSearch<
placeholder=""
selectedKey={selectedKey}
onSelectionChange={onSelectionChange as (key: Key | null) => void}
aria-label="User search"
{...rest}
>
{label ? (

View File

@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import {
SendouSelect,
SendouSelectItem,
type SendouSelectProps,
} from "~/components/elements/Select";
interface TagSelectProps<T extends object>
extends Omit<SendouSelectProps<T>, "items" | "children"> {
tags: Array<{ id: number; name: string }>;
}
export function TagSelect<T extends object>({
tags,
...rest
}: TagSelectProps<T>) {
const { t } = useTranslation(["art"]);
return (
<SendouSelect
items={tags}
placeholder={t("art:forms.tags.placeholder")}
search={{ placeholder: t("art:forms.tags.search.placeholder") }}
aria-label={t("art:forms.tags.placeholder")}
{...rest}
>
{({ id, name, ...item }) => (
<SendouSelectItem key={id} id={name} {...item}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
);
}

View File

@ -6,7 +6,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router-dom";
import { Alert } from "~/components/Alert";
import { Combobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@ -25,6 +24,7 @@ import {
import { metaTitle } from "../../../utils/remix";
import { ART } from "../art-constants";
import { previewUrl } from "../art-utils";
import { TagSelect } from "../components/TagSelect";
import { action } from "../actions/art.new.server";
import { loader } from "../loaders/art.new.server";
@ -205,11 +205,6 @@ function Tags() {
);
const [newTagValue, setNewTagValue] = React.useState("");
const existingTags = data.tags;
const unselectedTags = existingTags.filter(
(t) => !tags.some((tag) => tag.id === t.id),
);
const handleAddNewTag = () => {
const normalizedNewTagValue = newTagValue
.trim()
@ -224,7 +219,7 @@ function Tags() {
return;
}
const alreadyCreatedTag = existingTags.find(
const alreadyCreatedTag = data.tags.find(
(t) => t.name === normalizedNewTagValue,
);
@ -287,23 +282,14 @@ function Tags() {
</SendouButton>
</div>
) : (
<Combobox
<TagSelect
// empty combobox on select
key={tags.length}
options={unselectedTags.map((t) => ({
label: t.name,
value: String(t.id),
}))}
inputName="tags"
placeholder={t("art:forms.tags.searchExisting.placeholder")}
initialValue={null}
onChange={(selection) => {
if (!selection) return;
setTags([
...tags,
{ name: selection.label, id: Number(selection.value) },
]);
}}
tags={data.tags}
disabledKeys={tags.map((t) => t.id).filter((id) => id !== undefined)}
onSelectionChange={(tagName) =>
setTags([...tags, data.tags.find((t) => t.name === tagName)!])
}
/>
)}
<div className="text-sm stack sm flex-wrap horizontal">

View File

@ -3,7 +3,6 @@ import type { ShouldRevalidateFunction } from "@remix-run/react";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { AddNewButton } from "~/components/AddNewButton";
import { Combobox } from "~/components/Combobox";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SendouButton } from "~/components/elements/Button";
@ -14,6 +13,7 @@ import { artPage, navIconUrl, newArtPage } from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants";
import { ArtGrid } from "../components/ArtGrid";
import { TagSelect } from "../components/TagSelect";
import { loader } from "../loaders/art.server";
export { loader };
@ -87,20 +87,12 @@ export default function ArtPage() {
</Label>
</div>
<div className="stack horizontal sm items-center">
<Combobox
<TagSelect
key={filteredTag}
options={data.allTags.map((t) => ({
label: t.name,
value: String(t.id),
}))}
inputName="tags"
placeholder={t("art:filterByTag")}
initialValue={null}
onChange={(selection) => {
if (!selection) return;
tags={data.allTags}
onSelectionChange={(tagName) => {
setSearchParams((prev) => {
prev.set(FILTERED_TAG_KEY_SEARCH_PARAM_KEY, selection.label);
prev.set(FILTERED_TAG_KEY_SEARCH_PARAM_KEY, tagName as string);
return prev;
});
}}

View File

@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next";
import { AbilitiesSelector } from "~/components/AbilitiesSelector";
import { Ability } from "~/components/Ability";
import Chart from "~/components/Chart";
import { WeaponCombobox } from "~/components/Combobox";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { Table } from "~/components/Table";
@ -86,6 +85,7 @@ import {
} from "../core/utils";
import "../analyzer.css";
import * as R from "remeda";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SendouSwitch } from "~/components/elements/Switch";
export const CURRENT_PATCH = "10.0";
@ -246,15 +246,14 @@ function BuildAnalyzerPage() {
<div className="analyzer__left-column">
<div className="stack sm items-center w-full">
<div className="w-full">
<WeaponCombobox
inputName="weapon"
onChange={(opt) =>
opt &&
<WeaponSelect
label={t("analyzer:weaponSelect.label")}
initialValue={mainWeaponId}
onChange={(val) =>
handleChange({
newMainWeaponId: Number(opt.value) as MainWeaponId,
newMainWeaponId: val,
})
}
fullWidth
/>
</div>
</div>

View File

@ -15,7 +15,6 @@ import type {
TournamentSettings,
} from "~/db/tables";
import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import {
databaseTimestampNow,
@ -429,29 +428,6 @@ function tagsArray(args: {
return tags;
}
export async function findRecentMapPoolsByAuthorId(authorId: number) {
const rows = await db
.selectFrom("CalendarEvent")
.innerJoin("MapPoolMap", "CalendarEvent.id", "MapPoolMap.calendarEventId")
.select(({ eb }) => [
"CalendarEvent.id",
"CalendarEvent.name",
withMapPool(eb),
])
.where("CalendarEvent.authorId", "=", authorId)
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEvent.id", "desc")
.groupBy("CalendarEvent.id")
.limit(5)
.execute();
return rows.map((row) => ({
id: row.id,
name: row.name,
serializedMapPool: MapPool.serialize(row.mapPool),
}));
}
export async function findResultsByEventId(eventId: number) {
return db
.selectFrom("CalendarEventResultTeam")
@ -483,31 +459,6 @@ export async function findResultsByEventId(eventId: number) {
.execute();
}
export async function allEventsWithMapPools() {
const rows = await db
.selectFrom("CalendarEvent")
.select(({ eb }) => [
"CalendarEvent.id",
"CalendarEvent.name",
jsonArrayFrom(
eb
.selectFrom("MapPoolMap")
.select(["MapPoolMap.stageId", "MapPoolMap.mode"])
.whereRef("MapPoolMap.calendarEventId", "=", "CalendarEvent.id"),
).as("mapPool"),
])
.innerJoin("MapPoolMap", "CalendarEvent.id", "MapPoolMap.calendarEventId")
.groupBy("CalendarEvent.id")
.orderBy("CalendarEvent.id", "desc")
.execute();
return rows.map((row) => ({
id: row.id,
name: row.name,
serializedMapPool: MapPool.serialize(row.mapPool),
}));
}
type CreateArgs = Pick<
Tables["CalendarEvent"],
| "name"

View File

@ -70,8 +70,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
eventToEdit?.tournament,
),
managedBadges: await BadgeRepository.findManagedByUserId(user.id),
recentEventsWithMapPools:
await CalendarRepository.findRecentMapPoolsByAuthorId(user.id),
eventToEdit: canEditEvent ? eventToEdit : undefined,
eventToCopy:
user.roles.includes("TOURNAMENT_ADDER") && !eventToEdit

View File

@ -24,8 +24,8 @@ import {
calendarEditPage,
calendarEventPage,
calendarReportWinnersPage,
mapsPageWithMapPool,
navIconUrl,
readonlyMapsPage,
resolveBaseUrl,
userPage,
} from "~/utils/urls";
@ -271,13 +271,15 @@ function MapPoolInfo() {
if (!data.event.mapPool || data.event.mapPool.length === 0) return null;
const mapPool = new MapPool(data.event.mapPool);
return (
<Section title={t("calendar:forms.mapPool")}>
<div className="event__map-pool-section">
<MapPoolStages mapPool={new MapPool(data.event.mapPool)} />
<MapPoolStages mapPool={mapPool} />
<LinkButton
className="event__create-map-list-link"
to={readonlyMapsPage(data.event.eventId)}
to={mapsPageWithMapPool(mapPool)}
variant="outlined"
size="small"
>

View File

@ -1073,8 +1073,7 @@ const mapPickingStyleToShort: Record<
function TournamentMapPickingStyleSelect() {
const { t } = useTranslation(["common"]);
const id = React.useId();
const { eventToEdit, recentEventsWithMapPools } =
useLoaderData<typeof loader>();
const { eventToEdit } = useLoaderData<typeof loader>();
const baseEvent = useBaseEvent();
const [mode, setMode] = React.useState<"ALL" | "TO" | RankedModeShort>(
baseEvent?.mapPickingStyle
@ -1123,7 +1122,6 @@ function TournamentMapPickingStyleSelect() {
mapPool={mapPool}
title={t("common:maps.mapPool")}
handleMapPoolChange={setMapPool}
recentEvents={recentEventsWithMapPools}
allowBulkEdit
/>
</>
@ -1136,7 +1134,6 @@ function MapPoolSection() {
const { t } = useTranslation(["game-misc", "common"]);
const baseEvent = useBaseEvent();
const { recentEventsWithMapPools } = useLoaderData<typeof loader>();
const [mapPool, setMapPool] = React.useState<MapPool>(
baseEvent?.mapPool ? new MapPool(baseEvent.mapPool) : MapPool.EMPTY,
);
@ -1156,7 +1153,6 @@ function MapPoolSection() {
title={t("common:maps.mapPool")}
handleRemoval={() => setIncludeMapPool(false)}
handleMapPoolChange={setMapPool}
recentEvents={recentEventsWithMapPools}
allowBulkEdit
/>
</>

View File

@ -1,12 +0,0 @@
import type { SerializeFrom } from "@remix-run/node";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
export const loader = async () => {
return {
events: await CalendarRepository.allEventsWithMapPools(),
};
};
export type EventsWithMapPoolsLoaderData = SerializeFrom<typeof loader>;
export type SerializedMapPoolEvent =
EventsWithMapPoolsLoaderData["events"][number];

View File

@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import { WeaponCombobox } from "~/components/Combobox";
import { WeaponImage } from "~/components/Image";
import { Label } from "~/components/Label";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SendouButton } from "~/components/elements/Button";
import { CrossIcon } from "~/components/icons/Cross";
import type { Tables } from "~/db/tables";
@ -123,20 +123,18 @@ function WeaponFilterFields({
}) {
return (
<div className="stack horizontal sm flex-wrap">
<WeaponCombobox
inputName="weapon"
key={value.length}
weaponIdsToOmit={new Set(value)}
onChange={(wpn) =>
wpn &&
<WeaponSelect
disabledWeaponIds={value}
onChange={(weaponId) =>
changeFilter({
_tag: "Weapon",
weaponSplIds:
value.length >= 10
? [...value.slice(1, 10), Number(wpn.value) as MainWeaponId]
: [...value, Number(wpn.value) as MainWeaponId],
? [...value.slice(1, 10), weaponId]
: [...value, weaponId],
})
}
key={value.join("-")}
/>
{value.map((weapon) => (
<SendouButton

View File

@ -1,29 +0,0 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getUserId } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
const url = new URL(request.url);
const calendarEventId = url.searchParams.get("eventId");
const event = calendarEventId
? await CalendarRepository.findById({
id: Number(calendarEventId),
includeMapPool: true,
})
: undefined;
return {
calendarEvent: event
? {
id: event.eventId,
name: event.name,
mapPool: event.mapPool,
}
: undefined,
recentEventsWithMapPools: user
? await CalendarRepository.findRecentMapPoolsByAuthorId(user.id)
: undefined,
};
};

View File

@ -1,6 +1,6 @@
import type { MetaFunction } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import { useSearchParams } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
@ -9,19 +9,13 @@ import { Main } from "~/components/Main";
import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { EditIcon } from "~/components/icons/Edit";
import type { Tables } from "~/db/tables";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeWithStage } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
MAPS_URL,
calendarEventPage,
ipLabsMaps,
navIconUrl,
} from "~/utils/urls";
import { MAPS_URL, ipLabsMaps, navIconUrl } from "~/utils/urls";
import { generateMapList } from "../core/map-list-generator/map-list";
import { modesOrder } from "../core/map-list-generator/modes";
import { mapPoolToNonEmptyModes } from "../core/map-list-generator/utils";
@ -29,9 +23,6 @@ import { MapPool } from "../core/map-pool";
import styles from "./maps.module.css";
import { loader } from "../loaders/maps.server";
export { loader };
const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2;
export const shouldRevalidate: ShouldRevalidateFunction = ({ nextUrl }) => {
@ -62,39 +53,17 @@ export const handle: SendouRouteHandle = {
export default function MapListPage() {
const { t } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const { mapPool, handleMapPoolChange, readonly, switchToEditMode } =
const { mapPool, handleMapPoolChange, readonly } =
useSearchParamPersistedMapPool();
return (
<Main className={`${styles.container} stack lg`}>
{searchParams.has("readonly") && data.calendarEvent && (
<div className={styles.poolMeta}>
<div className={styles.poolInfo}>
{t("common:maps.mapPool")}:{" "}
<Link to={calendarEventPage(data.calendarEvent.id)}>
{data.calendarEvent.name}
</Link>
</div>
<SendouButton
variant="outlined"
onPress={switchToEditMode}
size="small"
icon={<EditIcon />}
>
{t("common:actions.edit")}
</SendouButton>
</div>
)}
{readonly ? (
<MapPoolStages mapPool={mapPool} />
) : (
<MapPoolSelector
mapPool={mapPool}
handleMapPoolChange={handleMapPoolChange}
recentEvents={data.recentEventsWithMapPools}
initialEvent={data.calendarEvent}
allowBulkEdit
className={styles.poolSelector}
/>
@ -113,7 +82,6 @@ export default function MapListPage() {
}
export function useSearchParamPersistedMapPool() {
const data = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const [mapPool, setMapPool] = React.useState(() => {
@ -121,10 +89,6 @@ export function useSearchParamPersistedMapPool() {
return new MapPool(searchParams.get("pool")!);
}
if (data.calendarEvent?.mapPool) {
return new MapPool(data.calendarEvent.mapPool);
}
return MapPool.ANARCHY;
});
@ -143,20 +107,10 @@ export function useSearchParamPersistedMapPool() {
);
};
const switchToEditMode = () => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.delete("readonly");
setSearchParams(newSearchParams, {
replace: false,
preventScrollReset: true,
});
};
return {
mapPool,
readonly: searchParams.has("readonly"),
handleMapPoolChange,
switchToEditMode,
};
}

View File

@ -3,11 +3,10 @@ import clsx from "clsx";
import React from "react";
import { useTranslation } from "react-i18next";
import { Ability } from "~/components/Ability";
import { AllWeaponCombobox } from "~/components/Combobox";
import { Image, WeaponImage } from "~/components/Image";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import type { AnyWeapon, DamageType } from "~/features/build-analyzer";
import type { DamageType } from "~/features/build-analyzer";
import { possibleApValues } from "~/features/build-analyzer";
import {
BIG_BUBBLER_ID,
@ -39,6 +38,7 @@ import { useObjectDamage } from "../calculator-hooks";
import type { DamageReceiver } from "../calculator-types";
import "../calculator.css";
import type { MetaFunction } from "@remix-run/node";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SendouSwitch } from "~/components/elements/Switch";
import { metaTags } from "~/utils/remix";
@ -84,22 +84,13 @@ export default function ObjectDamagePage() {
<div className="object-damage__selects">
<div className="object-damage__selects__weapon">
<Label htmlFor="weapon">{t("analyzer:labels.weapon")}</Label>
<AllWeaponCombobox
id="weapon"
inputName="weapon"
onChange={(opt) => {
if (!opt) return;
const [type, id] = opt.value.split("_");
<WeaponSelect
includeSubSpecial
onChange={(newAnyWeapon) => {
handleChange({
newAnyWeapon: {
id: Number(id),
type,
} as AnyWeapon,
newAnyWeapon,
});
}}
fullWidth
/>
</div>
{allDamageTypes.length > 0 ? (

View File

@ -13,12 +13,12 @@ import { Flipped, Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { WeaponCombobox } from "~/components/Combobox";
import { Divider } from "~/components/Divider";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import { LinkButton } from "~/components/elements/Button";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
@ -30,7 +30,6 @@ import {
SendouTabs,
} from "~/components/elements/Tabs";
import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox";
import { CrossIcon } from "~/components/icons/Cross";
import { DiscordIcon } from "~/components/icons/Discord";
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
import { ScaleIcon } from "~/components/icons/Scale";
@ -42,7 +41,6 @@ import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPri
import type { ReportedWeaponForMerging } from "~/features/sendouq-match/core/reported-weapons.server";
import { GroupCard } from "~/features/sendouq/components/GroupCard";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useWindowSize } from "~/hooks/useWindowSize";
@ -414,8 +412,6 @@ function ReportWeaponsForm() {
const [reportingMode, setReportingMode] = React.useState<
"ALL" | "MYSELF" | "MY_TEAM"
>("MYSELF");
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
useRecentlyReportedWeapons();
const playedMaps = data.match.mapList.filter((m) => m.winnerGroupId);
const winners = playedMaps.map((m) =>
@ -479,7 +475,7 @@ function ReportWeaponsForm() {
name="weapons"
value={JSON.stringify(weaponsUsage)}
/>
<div className="stack horizontal sm justify-between w-max mx-auto">
<div className="stack horizontal md justify-between w-max mx-auto">
<h3 className="text-md">{t("q:match.report.whoToReport")}</h3>
<label className="stack horizontal xs items-center mb-0">
{t("q:match.report.whoToReport.me")}
@ -573,27 +569,9 @@ function ReportWeaponsForm() {
)}
</div>
<div className="stack horizontal sm items-center">
<WeaponImage
weaponSplId={weaponSplId ?? 0}
variant="badge"
width={32}
className={clsx("ml-auto", {
invisible: typeof weaponSplId !== "number",
})}
/>
<WeaponCombobox
inputName="weapon"
value={weaponSplId}
quickSelectWeaponIds={recentlyReportedWeapons}
onChange={(weapon) => {
if (!weapon) return;
const weaponSplId = Number(
weapon.value,
) as MainWeaponId;
addRecentlyReportedWeapon(weaponSplId);
<WeaponSelect
value={weaponSplId ?? undefined}
onChange={(weaponSplId) => {
setWeaponsUsage((val) => {
const result = val.filter(
(reportedWeapon) =>
@ -953,8 +931,6 @@ function MapList({
const [ownWeaponsUsage, setOwnWeaponsUsage] = React.useState<
ReportedWeaponForMerging[]
>([]);
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
useRecentlyReportedWeapons();
const previouslyReportedWinners = isResubmission
? data.match.mapList
@ -1000,11 +976,6 @@ function MapList({
setWinners={setWinners}
weapons={data.reportedWeapons?.[i]}
showReportedOwnWeapon={!ownWeaponReported}
recentlyReportedWeapons={recentlyReportedWeapons}
addRecentlyReportedWeapon={addRecentlyReportedWeapon}
ownWeapon={
ownWeaponsUsage.find((w) => w.mapIndex === i)?.weaponSplId
}
onOwnWeaponSelected={(newReportedWeapon) => {
if (!newReportedWeapon) return;
@ -1060,11 +1031,8 @@ function MapListMap({
setWinners,
canReportScore,
weapons,
ownWeapon,
onOwnWeaponSelected,
showReportedOwnWeapon,
recentlyReportedWeapons,
addRecentlyReportedWeapon,
}: {
i: number;
map: Unpacked<SerializeFrom<typeof loader>["match"]["mapList"]>;
@ -1072,11 +1040,8 @@ function MapListMap({
setWinners?: (winners: ("ALPHA" | "BRAVO")[]) => void;
canReportScore: boolean;
weapons?: (MainWeaponId | null)[] | null;
ownWeapon?: MainWeaponId | null;
onOwnWeaponSelected?: (weapon: ReportedWeaponForMerging | null) => void;
showReportedOwnWeapon: boolean;
recentlyReportedWeapons?: MainWeaponId[];
addRecentlyReportedWeapon?: (weapon: MainWeaponId) => void;
}) {
const user = useUser();
const data = useLoaderData<typeof loader>();
@ -1230,15 +1195,6 @@ function MapListMap({
<label className="mb-0 text-theme-secondary">
{t("q:match.report.winnerLabel")}
</label>
<div className="stack items-center">
<div
className={clsx("q-match__result-dot", {
"q-match__result-dot__won": winners[i] === data.groupMemberOf,
"q-match__result-dot__lost":
winners[i] && winners[i] !== data.groupMemberOf,
})}
/>
</div>
<div className="stack sm horizontal items-center">
<div className="stack sm horizontal items-center font-semi-bold">
<input
@ -1273,68 +1229,24 @@ function MapListMap({
<label className="mb-0 text-theme-secondary">
{t("q:match.report.weaponLabel")}
</label>
<div
className={clsx({ invisible: typeof ownWeapon !== "number" })}
>
{typeof ownWeapon === "number" ? (
<WeaponImage
weaponSplId={ownWeapon}
variant="badge"
size={36}
/>
) : (
<WeaponImage
weaponSplId={0}
variant="badge"
size={36}
className="invisible"
/>
)}
</div>
{typeof ownWeapon === "number" ? (
<div className="font-bold stack sm horizontal">
{t(`weapons:MAIN_${ownWeapon}`)}
<SendouButton
size="small"
icon={<CrossIcon />}
variant="minimal-destructive"
onPress={() => {
const userId = user!.id;
const groupMatchMapId = map.id;
<WeaponSelect
clearable
onChange={(weaponSplId) => {
const userId = user!.id;
const groupMatchMapId = map.id;
onOwnWeaponSelected({
mapIndex: i,
groupMatchMapId,
userId,
});
}}
/>
</div>
) : (
<WeaponCombobox
inputName="weapon"
quickSelectWeaponIds={recentlyReportedWeapons}
onChange={(weapon) => {
const userId = user!.id;
const groupMatchMapId = map.id;
const weaponSplId = Number(weapon?.value) as MainWeaponId;
addRecentlyReportedWeapon?.(weaponSplId);
onOwnWeaponSelected(
weapon
? {
weaponSplId,
mapIndex: i,
groupMatchMapId,
userId,
}
: null,
);
}}
/>
)}
onOwnWeaponSelected(
typeof weaponSplId === "number"
? {
weaponSplId,
mapIndex: i,
groupMatchMapId,
userId,
}
: null,
);
}}
/>
</>
) : null}
</div>

View File

@ -4,12 +4,12 @@ import * as React from "react";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { WeaponCombobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ModeImage, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { CrossIcon } from "~/components/icons/Cross";
@ -29,7 +29,7 @@ import {
import { useIsMounted } from "~/hooks/useIsMounted";
import { languagesUnified } from "~/modules/i18n/config";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists/types";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
@ -402,33 +402,27 @@ function WeaponPool() {
/>
<div className="q-settings__weapon-pool-select-container">
{weapons.length < SENDOUQ_WEAPON_POOL_MAX_SIZE ? (
<div>
<WeaponCombobox
inputName="weapon"
id="weapon"
onChange={(weapon) => {
if (!weapon) return;
setWeapons([
...weapons,
{
weaponSplId: Number(weapon.value) as MainWeaponId,
isFavorite: 0,
},
]);
}}
// empty on selection
key={latestWeapon ?? "empty"}
weaponIdsToOmit={new Set(weapons.map((w) => w.weaponSplId))}
fullWidth
/>
</div>
<WeaponSelect
onChange={(weaponSplId) => {
setWeapons([
...weapons,
{
weaponSplId,
isFavorite: 0,
},
]);
}}
// empty on selection
key={latestWeapon ?? "empty"}
disabledWeaponIds={weapons.map((w) => w.weaponSplId)}
/>
) : (
<span className="text-xs text-info">
{t("q:settings.weaponPool.full")}
</span>
)}
</div>
<div className="stack horizontal sm justify-center">
<div className="stack horizontal md justify-center">
{weapons.map((weapon) => {
return (
<div key={weapon.weaponSplId} className="stack xs">

View File

@ -1,45 +0,0 @@
import * as React from "react";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
const LOCAL_STORAGE_KEY = "sq__reported-weapons";
const MAX_REPORTED_WEAPONS = 6;
export function useRecentlyReportedWeapons() {
const [recentlyReportedWeapons, setReportedWeapons] = React.useState<
MainWeaponId[]
>([]);
React.useEffect(() => {
setReportedWeapons(getReportedWeaponsFromLocalStorage());
}, []);
const addRecentlyReportedWeapon = (weapon: MainWeaponId) => {
const newList = addReportedWeaponToLocalStorage(weapon);
setReportedWeapons(newList);
};
return { recentlyReportedWeapons, addRecentlyReportedWeapon };
}
const getReportedWeaponsFromLocalStorage = (): MainWeaponId[] => {
const stored = localStorage.getItem(LOCAL_STORAGE_KEY);
if (!stored) return [];
return JSON.parse(stored);
};
/** Adds weapon to list of recently reported weapons to local storage returning the current list */
const addReportedWeaponToLocalStorage = (weapon: MainWeaponId) => {
const stored = getReportedWeaponsFromLocalStorage();
const otherWeapons = stored.filter((storedWeapon) => storedWeapon !== weapon);
if (otherWeapons.length >= MAX_REPORTED_WEAPONS) {
otherWeapons.pop();
}
const newList = [weapon, ...otherWeapons];
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newList));
return newList;
};

View File

@ -408,28 +408,13 @@
.q-match__report-section {
display: grid;
grid-template-columns: max-content max-content 1fr;
grid-template-columns: max-content 1fr;
row-gap: var(--s-2);
column-gap: var(--s-2-5);
align-items: center;
font-size: var(--fonts-xs);
}
.q-match__result-dot {
width: 12px;
height: 12px;
border-radius: 100%;
background-color: var(--text-lighter);
}
.q-match__result-dot__won {
background-color: var(--theme-success);
}
.q-match__result-dot__lost {
background-color: var(--theme-error);
}
.q-match__pool-pass-container {
display: flex;
gap: var(--s-2);

View File

@ -15,6 +15,9 @@ interface PlacementsTableProps {
type?: "PLAYER_NAME" | "MODE_INFO";
}
const TENTATEK_BRAND_ID = "B10";
const TAKOROKA_BRAND_ID = "B11";
export function PlacementsTable({
placements,
type = "PLAYER_NAME",
@ -46,7 +49,9 @@ export function PlacementsTable({
: "Takoroka Division"
}
path={brandImageUrl(
placement.region === "WEST" ? "tentatek" : "takoroka",
placement.region === "WEST"
? TENTATEK_BRAND_ID
: TAKOROKA_BRAND_ID,
)}
width={24}
/>

View File

@ -1,12 +1,12 @@
import { Form, useLoaderData } from "@remix-run/react";
import React from "react";
import { useTranslation } from "react-i18next";
import { WeaponCombobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
import { WeaponImage } from "~/components/Image";
import { Label } from "~/components/Label";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SendouButton } from "~/components/elements/Button";
import { TrashIcon } from "~/components/icons/Trash";
import { useUser } from "~/features/auth/core/user";
@ -223,32 +223,24 @@ function WeaponPoolSelect({
name={id}
value={JSON.stringify(weapons)}
/>
<div>
<Label htmlFor={id} required={required}>
{label}
</Label>
{weapons.length < TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE ? (
<>
<WeaponCombobox
inputName={id}
id={id}
onChange={(weapon) => {
if (!weapon) return;
setWeapons([...weapons, Number(weapon.value) as MainWeaponId]);
}}
// empty on selection
key={weapons[weapons.length - 1]}
weaponIdsToOmit={new Set([...weapons, ...otherWeapons])}
fullWidth
/>
<FormMessage type="info">{infoText}</FormMessage>
</>
) : (
<span className="text-xs text-warning">
{t("user:forms.errors.maxWeapons")}
</span>
)}
</div>
{weapons.length < TOURNAMENT_SUB.WEAPON_POOL_MAX_SIZE ? (
<>
<WeaponSelect
label={label}
onChange={(weaponId) => {
setWeapons([...weapons, weaponId]);
}}
disabledWeaponIds={[...weapons, ...otherWeapons]}
// empty on selection
key={weapons[weapons.length - 1]}
/>
<FormMessage type="info">{infoText}</FormMessage>
</>
) : (
<span className="text-xs text-warning">
{t("user:forms.errors.maxWeapons")}
</span>
)}
{weapons.length > 0 ? (
<div className="stack horizontal sm justify-center">
{weapons.map((weapon) => {

View File

@ -46,8 +46,8 @@ import {
LOG_IN_URL,
SENDOU_INK_BASE_URL,
SENDOU_INK_DISCORD_URL,
mapsPageWithMapPool,
navIconUrl,
readonlyMapsPage,
tournamentJoinPage,
tournamentOrganizationPage,
tournamentSubsPage,
@ -1319,13 +1319,15 @@ function TOPickedMapPoolInfo() {
if (tournament.ctx.toSetMapPool.length === 0) return null;
const mapPool = new MapPool(tournament.ctx.toSetMapPool);
return (
<Section title={t("calendar:forms.mapPool")}>
<div className="event__map-pool-section">
<MapPoolStages mapPool={new MapPool(tournament.ctx.toSetMapPool)} />
<MapPoolStages mapPool={mapPool} />
<LinkButton
className="event__create-map-list-link"
to={readonlyMapsPage(tournament.ctx.eventId)}
to={mapsPageWithMapPool(mapPool)}
variant="outlined"
size="small"
>

View File

@ -28,6 +28,7 @@ import {
clothesMainSlotAbility,
dbBoolean,
falsyToNull,
filterOutNullishMembers,
headMainSlotAbility,
id,
processMany,
@ -35,7 +36,6 @@ import {
safeJSONParse,
shoesMainSlotAbility,
stackableAbility,
toArray,
weaponSplId,
} from "~/utils/zod";
@ -60,18 +60,17 @@ export const action: ActionFunction = async ({ request }) => {
"Build to edit not found",
);
const someGearIsMissing =
!data["HEAD[value]"] || !data["CLOTHES[value]"] || !data["SHOES[value]"];
const someGearIsMissing = !data.HEAD || !data.CLOTHES || !data.SHOES;
const commonArgs = {
title: data.title,
description: data.description,
abilities: data.abilities as BuildAbilitiesTuple,
headGearSplId: (someGearIsMissing ? -1 : data["HEAD[value]"])!,
clothesGearSplId: (someGearIsMissing ? -1 : data["CLOTHES[value]"])!,
shoesGearSplId: (someGearIsMissing ? -1 : data["SHOES[value]"])!,
headGearSplId: (someGearIsMissing ? -1 : data.HEAD)!,
clothesGearSplId: (someGearIsMissing ? -1 : data.CLOTHES)!,
shoesGearSplId: (someGearIsMissing ? -1 : data.SHOES)!,
modes: modesShort.filter((mode) => data[mode]),
weaponSplIds: data["weapon[value]"],
weaponSplIds: data.weapons,
ownerId: user.id,
private: data.private,
};
@ -115,11 +114,11 @@ const newBuildActionSchema = z.object({
RM: z.preprocess(checkboxValueToBoolean, z.boolean()),
CB: z.preprocess(checkboxValueToBoolean, z.boolean()),
private: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
"weapon[value]": z.preprocess(
processMany(toArray, removeDuplicatesZod),
weapons: z.preprocess(
processMany(safeJSONParse, filterOutNullishMembers, removeDuplicatesZod),
z.array(weaponSplId).min(1).max(BUILD.MAX_WEAPONS_COUNT),
),
"HEAD[value]": z.preprocess(
HEAD: z.preprocess(
actualNumber,
z
.number()
@ -130,7 +129,7 @@ const newBuildActionSchema = z.object({
headGearIds.includes(val as (typeof headGearIds)[number]),
),
),
"CLOTHES[value]": z.preprocess(
CLOTHES: z.preprocess(
actualNumber,
z
.number()
@ -141,7 +140,7 @@ const newBuildActionSchema = z.object({
clothesGearIds.includes(val as (typeof clothesGearIds)[number]),
),
),
"SHOES[value]": z.preprocess(
SHOES: z.preprocess(
actualNumber,
z
.number()

View File

@ -8,13 +8,14 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { AbilitiesSelector } from "~/components/AbilitiesSelector";
import { Alert } from "~/components/Alert";
import { GearCombobox, WeaponCombobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
import { GearSelect } from "~/components/GearSelect";
import { Image } from "~/components/Image";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SendouButton } from "~/components/elements/Button";
import { CrossIcon } from "~/components/icons/Cross";
import { PlusIcon } from "~/components/icons/Plus";
@ -23,6 +24,7 @@ import {
validatedBuildFromSearchParams,
validatedWeaponIdFromSearchParams,
} from "~/features/build-analyzer";
import { BUILD } from "~/features/builds/builds-constants";
import { modesShort } from "~/modules/in-game-lists/modes";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type {
@ -34,7 +36,6 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import { modeImageUrl } from "~/utils/urls";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
import { BUILD } from "~/features/builds/builds-constants";
import { action } from "../actions/u.$identifier.builds.new.server";
import { loader } from "../loaders/u.$identifier.builds.new.server";
export { loader, action };
@ -86,6 +87,7 @@ export default function NewBuildPage() {
abilities={abilities}
setAbilities={setAbilities}
/>
<div /> {/* spacer */}
<Abilities abilities={abilities} setAbilities={setAbilities} />
<TitleInput />
<DescriptionTextarea />
@ -200,7 +202,7 @@ function WeaponsSelector() {
const [searchParams] = useSearchParams();
const { buildToEdit } = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "weapons", "builds"]);
const [weapons, setWeapons] = React.useState(
const [weapons, setWeapons] = React.useState<Array<MainWeaponId | null>>(
buildToEdit?.weapons.map((wpn) => wpn.weaponSplId) ?? [
validatedWeaponIdFromSearchParams(searchParams),
],
@ -211,33 +213,28 @@ function WeaponsSelector() {
<Label required htmlFor="weapon">
{t("builds:forms.weapons")}
</Label>
<input type="hidden" name="weapons" value={JSON.stringify(weapons)} />
<div className="stack sm">
{weapons.map((weapon, i) => {
return (
<div key={i} className="stack horizontal sm items-center">
<div>
<WeaponCombobox
inputName="weapon"
id="weapon"
className="u__build-form__weapon"
required
onChange={(opt) =>
opt &&
setWeapons((weapons) => {
const newWeapons = [...weapons];
newWeapons[i] = Number(opt.value) as MainWeaponId;
return newWeapons;
})
}
initialWeaponId={weapon ?? undefined}
/>
</div>
<WeaponSelect
onChange={(weaponId) =>
setWeapons((weapons) => {
const newWeapons = [...weapons];
newWeapons[i] = weaponId;
return newWeapons;
})
}
initialValue={weapon ?? undefined}
testId={`weapon-${i}`}
/>
{i === weapons.length - 1 && (
<>
<SendouButton
size="small"
isDisabled={weapons.length === BUILD.MAX_WEAPONS_COUNT}
onPress={() => setWeapons((weapons) => [...weapons, 0])}
onPress={() => setWeapons((weapons) => [...weapons, null])}
icon={<PlusIcon />}
data-testid="add-weapon-button"
/>
@ -276,56 +273,52 @@ function GearSelector({
}) {
const { buildToEdit, gearIdToAbilities } = useLoaderData<typeof loader>();
const { t } = useTranslation("builds");
const initialGearId = () => {
const [value, setValue] = React.useState(() => {
const gearId = !buildToEdit
? undefined
? null
: type === "HEAD"
? buildToEdit.headGearSplId
: type === "CLOTHES"
? buildToEdit.clothesGearSplId
: buildToEdit.shoesGearSplId;
if (gearId === -1) return undefined;
if (gearId === -1) return null;
return gearId;
};
});
return (
<div>
<Label htmlFor={type}>{t(`forms.gear.${type}`)}</Label>
<div>
<GearCombobox
gearType={type}
inputName={type}
id={type}
initialGearId={initialGearId()}
nullable
// onChange only exists to copy abilities from existing gear
// actual value of combobox is handled in uncontrolled manner
onChange={(opt) => {
if (!opt) return;
<>
<input type="hidden" name={type} value={value ?? ""} />
<GearSelect
label={t(`forms.gear.${type}`)}
type={type}
value={value}
clearable
onChange={(gearId) => {
setValue(gearId);
const abilitiesFromExistingGear =
gearIdToAbilities[`${type}_${opt.value}`];
if (!gearId) return;
if (!abilitiesFromExistingGear) return;
const abilitiesFromExistingGear =
gearIdToAbilities[`${type}_${gearId}`];
const gearIndex = type === "HEAD" ? 0 : type === "CLOTHES" ? 1 : 2;
if (!abilitiesFromExistingGear) return;
const currentAbilities = abilities[gearIndex];
const gearIndex = type === "HEAD" ? 0 : type === "CLOTHES" ? 1 : 2;
// let's not overwrite current selections
if (!currentAbilities.every((a) => a === "UNKNOWN")) return;
const currentAbilities = abilities[gearIndex];
const newAbilities = structuredClone(abilities);
newAbilities[gearIndex] = abilitiesFromExistingGear;
// let's not overwrite current selections
if (!currentAbilities.every((a) => a === "UNKNOWN")) return;
setAbilities(newAbilities);
}}
/>
</div>
</div>
const newAbilities = structuredClone(abilities);
newAbilities[gearIndex] = abilitiesFromExistingGear;
setAbilities(newAbilities);
}}
/>
</>
);
}

View File

@ -2,7 +2,6 @@ import { Form, Link, useLoaderData, useMatches } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { WeaponCombobox } from "~/components/Combobox";
import { CustomizedColorsInput } from "~/components/CustomizedColorsInput";
import { FormErrors } from "~/components/FormErrors";
import { FormMessage } from "~/components/FormMessage";
@ -10,6 +9,7 @@ import { WeaponImage } from "~/components/Image";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SendouButton } from "~/components/elements/Button";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import { SendouSwitch } from "~/components/elements/Switch";
@ -20,7 +20,6 @@ import type { Tables } from "~/db/tables";
import { BADGE } from "~/features/badges/badges-constants";
import { BadgesSelector } from "~/features/badges/components/BadgesSelector";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useHasRole } from "~/modules/permissions/hooks";
import invariant from "~/utils/invariant";
import { rawSensToString } from "~/utils/strings";
@ -215,6 +214,7 @@ function CountrySelect() {
const { t, i18n } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const [value, setValue] = React.useState(data.user.country ?? null);
const displayName = new Intl.DisplayNames(i18n.language, { type: "region" });
@ -230,22 +230,26 @@ function CountrySelect() {
);
return (
<SendouSelect
items={items}
label={t("user:country")}
search={{
placeholder: t("user:forms.country.search.placeholder"),
}}
name="country"
defaultSelectedKey={data.user.country ?? undefined}
className={styles.countrySelect}
>
{({ key, ...item }) => (
<SendouSelectItem key={key} {...item}>
{item.name}
</SendouSelectItem>
)}
</SendouSelect>
<>
{/* TODO: this is a workaround for clearable not working with uncontrolled values, in future the component should handle this one way or another */}
<input type="hidden" name="country" value={value ?? ""} />
<SendouSelect
items={items}
label={t("user:country")}
search={{
placeholder: t("user:forms.country.search.placeholder"),
}}
selectedKey={value}
onSelectionChange={(value) => setValue(value as string | null)}
clearable
>
{({ key, ...item }) => (
<SendouSelectItem key={key} {...item}>
{item.name}
</SendouSelectItem>
)}
</SendouSelect>
</>
);
}
@ -278,33 +282,27 @@ function WeaponPoolSelect() {
return (
<div className={clsx("stack md", styles.weaponPool)}>
<input type="hidden" name="weapons" value={JSON.stringify(weapons)} />
<div>
<label htmlFor="weapon">{t("user:weaponPool")}</label>
{weapons.length < USER.WEAPON_POOL_MAX_SIZE ? (
<WeaponCombobox
inputName="weapon"
id="weapon"
onChange={(weapon) => {
if (!weapon) return;
setWeapons([
...weapons,
{
weaponSplId: Number(weapon.value) as MainWeaponId,
isFavorite: 0,
},
]);
}}
// empty on selection
key={latestWeapon?.weaponSplId ?? "empty"}
weaponIdsToOmit={new Set(weapons.map((w) => w.weaponSplId))}
fullWidth
/>
) : (
<span className="text-xs text-warning">
{t("user:forms.errors.maxWeapons")}
</span>
)}
</div>
{weapons.length < USER.WEAPON_POOL_MAX_SIZE ? (
<WeaponSelect
label={t("user:weaponPool")}
onChange={(weaponSplId) => {
setWeapons([
...weapons,
{
weaponSplId,
isFavorite: 0,
},
]);
}}
disabledWeaponIds={weapons.map((w) => w.weaponSplId)}
// empty on selection
key={latestWeapon?.weaponSplId ?? "empty"}
/>
) : (
<span className="text-xs text-warning">
{t("user:forms.errors.maxWeapons")}
</span>
)}
<div className="stack horizontal sm justify-center">
{weapons.map((weapon) => {
return (

View File

@ -1,5 +1,4 @@
import { useLoaderData } from "@remix-run/react";
import * as React from "react";
import {
Controller,
get,
@ -9,10 +8,10 @@ import {
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import { WeaponCombobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SendouButton } from "~/components/elements/Button";
import { UserSearch } from "~/components/elements/UserSearch";
import { AddFieldButton } from "~/components/form/AddFieldButton";
@ -20,7 +19,6 @@ import { RemoveFieldButton } from "~/components/form/RemoveFieldButton";
import type { Tables } from "~/db/tables";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useHasRole } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { Alert } from "../../../components/Alert";
@ -266,7 +264,6 @@ function MatchesFieldset({
canRemove: boolean;
videoType: Tables["Video"]["type"];
}) {
const id = React.useId();
const { t } = useTranslation(["vods", "game-misc"]);
return (
@ -317,16 +314,14 @@ function MatchesFieldset({
<div className="stack sm">
{new Array(4).fill(null).map((_, i) => {
return (
<WeaponCombobox
<WeaponSelect
key={i}
required
fullWidth
inputName={`player-${i}-weapon`}
initialWeaponId={value[i]}
onChange={(selected) => {
if (!selected) return;
isRequired
testId={`player-${i}-weapon`}
initialValue={value[i]}
onChange={(weaponId) => {
const weapons = [...value];
weapons[i] = Number(selected.value) as MainWeaponId;
weapons[i] = weaponId;
onChange(weapons);
}}
@ -342,18 +337,14 @@ function MatchesFieldset({
{new Array(4).fill(null).map((_, i) => {
const adjustedI = i + 4;
return (
<WeaponCombobox
<WeaponSelect
key={i}
required
fullWidth
inputName={`player-${adjustedI}-weapon`}
initialWeaponId={value[adjustedI]}
onChange={(selected) => {
if (!selected) return;
isRequired
testId={`player-${adjustedI}-weapon`}
initialValue={value[adjustedI]}
onChange={(weaponId) => {
const weapons = [...value];
weapons[adjustedI] = Number(
selected.value,
) as MainWeaponId;
weapons[adjustedI] = weaponId;
onChange(weapons);
}}
@ -364,25 +355,13 @@ function MatchesFieldset({
</div>
</div>
) : (
<>
<Label required htmlFor={id}>
{t("vods:forms.title.weapon")}
</Label>
<WeaponCombobox
id={id}
required
fullWidth
inputName={`match-${idx}-weapon`}
initialWeaponId={value[0]}
onChange={(selected) =>
onChange(
selected?.value
? [Number(selected.value) as MainWeaponId]
: [],
)
}
/>
</>
<WeaponSelect
label={t("vods:forms.title.weapon")}
isRequired
testId={`match-${idx}-weapon`}
initialValue={value[0]}
onChange={(weaponId) => onChange([weaponId])}
/>
)}
</div>
);

View File

@ -2,9 +2,9 @@ import type { MetaFunction } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { AddNewButton } from "~/components/AddNewButton";
import { WeaponCombobox } from "~/components/Combobox";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { WeaponSelect } from "~/components/WeaponSelect";
import { SendouButton } from "~/components/elements/Button";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
@ -144,17 +144,14 @@ function Filters({
</select>
</div>
<div>
<Label>{t("vods:forms.title.weapon")}</Label>
<WeaponCombobox
inputName="weapon"
initialWeaponId={weapon}
onChange={(selected) => {
addToSearchParams("weapon", selected?.value ?? "");
}}
nullable
/>
</div>
<WeaponSelect
label={t("vods:forms.title.weapon")}
initialValue={weapon}
onChange={(weaponId) => {
addToSearchParams("weapon", weaponId ?? "");
}}
clearable
/>
<div>
<Label>{t("vods:forms.title.type")}</Label>

View File

@ -1,11 +1,9 @@
import useSWRImmutable from "swr/immutable";
import type { EventsWithMapPoolsLoaderData } from "~/features/calendar/routes/map-pool-events";
import type { PatronsListLoaderData } from "~/features/front-page/routes/patrons-list";
import type { TrustersLoaderData } from "~/features/sendouq/routes/trusters";
import type { WeaponUsageLoaderData } from "~/features/sendouq/routes/weapon-usage";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import {
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
GET_TRUSTERS_ROUTE,
PATRONS_LIST_ROUTE,
getWeaponUsage,
@ -22,19 +20,6 @@ const fetcher = (key: string) => async (url: string) => {
return res.json();
};
export function useAllEventsWithMapPools() {
const { data, error } = useSWRImmutable<EventsWithMapPoolsLoaderData>(
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
fetcher(GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE),
);
return {
events: data?.events,
isLoading: !error && !data,
isError: error,
};
}
export function useWeaponUsage(args: {
userId: number;
season: number;

View File

@ -0,0 +1,23 @@
export const brandIds = [
"B00",
"B01",
"B02",
"B03",
"B04",
"B05",
"B06",
"B07",
"B08",
"B09",
"B10",
"B11",
"B15",
"B16",
"B17",
"B18",
"B19",
"B20",
"B97",
"B98",
"B99",
] as const;

View File

@ -1,79 +1,176 @@
export const headGearIds = [
1, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011,
1012, 1014, 1018, 1019, 1020, 1021, 1023, 1024, 1025, 1026, 1027, 1028, 1029,
1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 1039, 2000, 2001, 2002,
2003, 2004, 2005, 2008, 2009, 2010, 2011, 2012, 2013, 3000, 3001, 3002, 3003,
3004, 3005, 3006, 3007, 3008, 3009, 3010, 3011, 3012, 3013, 3014, 3015, 3016,
3017, 3018, 3021, 3022, 3023, 3024, 3025, 3026, 3027, 3028, 3029, 3030, 3031,
3032, 4000, 4001, 4002, 4003, 4004, 4005, 4006, 4007, 4008, 4009, 4010, 4011,
4012, 4013, 4014, 4015, 4016, 4017, 4018, 4019, 4020, 4021, 4022, 4023, 5000,
5001, 5002, 5003, 5004, 5007, 5008, 5009, 5010, 5011, 5012, 6000, 6001, 6002,
6003, 6004, 6005, 6006, 7000, 7002, 7004, 7005, 7006, 7007, 7008, 7009, 7011,
7012, 7013, 7014, 7015, 7016, 7017, 7018, 7019, 7020, 7021, 8000, 8001, 8002,
8003, 8004, 8005, 8006, 8007, 8008, 8009, 8010, 8011, 8012, 8014, 8015, 8016,
8017, 8018, 8019, 9001, 9002, 9003, 9004, 9005, 9007, 9008, 9009, 10003,
21000, 21001, 21002, 21003, 21004, 21005, 21007, 21008, 21009, 21010, 21011,
21012, 21013, 21014, 21015, 21016, 21017, 21018, 21019, 21020, 21021, 21022,
21023, 24000, 24001, 24002, 24003, 24004, 24005, 24006, 24007, 24008, 24009,
24010, 24011, 24012, 24013, 24014, 24015, 24016, 24017, 24018, 24019, 24020,
24021, 24022, 24023, 24024, 24025, 24026, 24027, 24028, 24029, 24030, 24031,
24032, 24033, 24034, 24035, 24036, 24037, 24038, 24039, 24040, 25000, 25001,
25002, 25003, 25004, 25005, 25006, 25007, 25008, 25009, 25010, 25011, 25012,
25013, 25016, 25017, 25018, 25019, 25020, 25021, 25022, 27000, 27004, 27109,
27306, 27307, 27311, 28000, 28001, 29200, 29201, 29202, 29203, 29204,
] as const;
export const headGearBrandGrouped = {
B00: [
1, 4008, 6005, 8004, 8007, 8015, 24000, 24001, 24002, 24003, 24004, 24005,
24006, 24007, 24012, 24013, 24014, 24015, 24016, 24017, 24018, 24019, 24020,
24021, 24022, 24023, 24024, 24025, 24026, 24027, 24028, 24029, 24030, 24031,
24032, 24033, 24034, 24035, 24036, 24037, 24038, 24039, 24040, 28000, 28001,
],
B01: [1012, 3016, 6000, 7021, 9001, 9002, 9009],
B02: [1010, 1018, 1020, 1021, 3009, 4018, 8002],
B03: [1033, 1037, 3008, 3014, 4017, 4023],
B04: [1007, 1008, 1009, 3003, 3004, 3006, 3012, 3013, 7006, 7008, 9004],
B05: [
2004, 3001, 3002, 3005, 4000, 4007, 5000, 5001, 5002, 5003, 5004, 5007,
5008, 5009, 5010, 7002, 7007, 7009, 7011, 7013, 7014, 7015, 7016, 7017,
7018, 8000, 8001, 8003, 8010, 8011, 8012,
],
B06: [1006, 1011, 1025, 2008, 3010, 4001, 4003, 4011, 6001, 8008, 9007, 9008],
B07: [
1000, 1003, 1004, 1005, 1023, 1026, 1029, 1030, 1036, 2003, 2005, 2011,
2012, 4005, 4006, 4009, 4013, 6006, 7000, 7004, 7005, 7012,
],
B08: [1024, 1027, 1031, 2000, 2002, 3000, 3011, 3028, 3031, 4015, 4016],
B09: [1001, 1038, 2001, 2010, 3025, 4002, 4004, 4010, 4012, 4014, 4019, 4022],
B10: [1014, 3007, 6002, 7020, 9003, 9005, 24008, 24009, 24010, 24011],
B11: [1002, 6003],
B15: [2009, 3015, 3027, 8005],
B16: [1019, 3022, 3024, 8006, 8009],
B17: [3017, 3018, 3023, 6004, 8016, 8018, 10003],
B18: [1034, 1035, 2013, 3030, 4020, 4021, 5011, 5012, 7019, 8019],
B19: [1028, 1032, 3026],
B20: [1039, 3021, 3029, 3032, 8014, 8017],
B97: [
21000, 21001, 21002, 21003, 21004, 21005, 21007, 21008, 21009, 21010, 21011,
21012, 21013, 21014, 21015, 21016, 21017, 21018, 21019, 21020, 21021, 21022,
21023,
],
B98: [
27000, 27004, 27109, 27306, 27307, 27311, 29200, 29201, 29202, 29203, 29204,
],
B99: [
25000, 25001, 25002, 25003, 25004, 25005, 25006, 25007, 25008, 25009, 25010,
25011, 25012, 25013, 25016, 25017, 25018, 25019, 25020, 25021, 25022,
],
} as const;
export const clothesGearIds = [
2, 3, 1000, 1001, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012,
1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1022, 1023, 1026, 1027,
1028, 1029, 1030, 1031, 1032, 1033, 1034, 1035, 1036, 1037, 1038, 1039, 1040,
1041, 1042, 1043, 1046, 1047, 1048, 1049, 1050, 1051, 1052, 1053, 1054, 1055,
1056, 1057, 1058, 1059, 1060, 1062, 1063, 1066, 1067, 1068, 1069, 1070, 1071,
1072, 1073, 1074, 1075, 1076, 1077, 1078, 1079, 1081, 1082, 1083, 1084, 1085,
1086, 1087, 1088, 1089, 1090, 1091, 1092, 1093, 1094, 1095, 2000, 2001, 2002,
2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015,
2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2026, 2027, 2028, 3000,
3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3010, 3011, 3013, 4000,
4001, 4002, 4003, 4004, 4005, 4006, 4007, 4008, 4009, 4010, 5000, 5001, 5002,
5003, 5004, 5005, 5006, 5007, 5008, 5009, 5010, 5011, 5012, 5013, 5014, 5015,
5016, 5017, 5018, 5019, 5020, 5021, 5022, 5023, 5024, 5025, 5026, 5027, 5028,
5029, 5033, 5034, 5035, 5036, 5038, 5039, 5040, 5041, 5042, 5043, 5045, 5046,
5047, 5048, 5049, 5050, 5051, 5052, 5053, 5054, 5055, 5056, 5057, 5058, 5059,
5060, 5061, 5062, 5063, 5064, 5065, 6000, 6001, 6003, 6004, 6005, 6006, 6007,
6008, 7000, 7001, 7002, 7003, 7004, 7005, 7006, 7007, 7008, 7009, 7010, 7011,
7012, 7013, 7014, 7015, 7016, 7017, 7018, 7019, 7020, 7021, 7022, 7023, 7024,
7025, 8000, 8001, 8002, 8003, 8004, 8005, 8006, 8007, 8008, 8009, 8010, 8011,
8012, 8013, 8014, 8015, 8017, 8018, 8019, 8020, 8021, 8022, 8023, 8024, 8025,
8026, 8027, 8028, 8030, 8031, 8032, 8033, 8034, 8035, 8036, 8037, 8038, 8039,
8040, 8041, 8042, 8043, 9000, 9001, 9002, 9003, 9004, 9005, 9007, 9008, 9009,
9010, 9011, 9012, 9013, 9014, 9015, 9016, 9017, 10000, 10001, 10002, 10004,
10005, 10006, 10007, 10008, 10009, 10010, 10012, 10013, 10014, 10015, 21000,
21001, 21002, 21003, 21004, 21005, 21006, 21008, 21009, 21010, 21011, 21012,
21013, 21014, 21015, 21016, 21017, 25000, 25001, 25002, 25003, 25004, 25005,
25006, 25007, 25008, 25009, 25010, 25011, 25012, 25013, 25014, 25015, 25017,
25018, 25019, 25020, 25021, 25022, 26000, 27000, 27004, 27306, 27307, 27311,
28000, 28001, 29200,
] as const;
export const headGearIds = Object.values(headGearBrandGrouped).flat();
export const shoesGearIds = [
1, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011,
1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1022, 1023, 1024,
1025, 1026, 1027, 2000, 2001, 2002, 2003, 2004, 2005, 2006, 2008, 2009, 2010,
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2022, 2023, 2024,
2025, 2026, 2027, 2028, 2029, 2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037,
2038, 2039, 2040, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049, 2050, 2051,
2052, 2053, 3000, 3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3010,
3011, 3012, 3013, 3014, 3015, 3016, 3017, 3018, 3019, 3020, 3021, 3022, 3023,
3024, 3025, 3026, 3027, 3028, 3029, 3030, 3031, 4000, 4001, 4002, 4003, 4007,
4008, 4009, 4010, 4011, 4012, 4013, 4014, 4015, 4016, 4017, 4018, 4019, 4020,
4021, 4022, 4023, 4024, 4025, 4026, 4027, 4028, 5000, 5001, 5002, 6000, 6001,
6002, 6003, 6004, 6005, 6006, 6007, 6008, 6009, 6010, 6011, 6012, 6013, 6014,
6016, 6017, 6018, 6019, 6020, 6021, 6022, 6023, 6024, 6025, 6026, 6027, 6028,
6029, 6030, 7000, 7001, 7002, 7003, 7004, 8000, 8001, 8002, 8003, 8004, 8005,
8006, 8007, 8008, 8009, 8010, 8011, 8013, 8014, 8015, 8016, 21001, 21002,
21003, 21004, 21005, 21006, 21007, 21008, 21009, 23000, 23001, 23002, 23003,
23004, 23005, 23006, 23007, 23008, 23009, 23010, 23011, 23012, 23013, 23014,
23015, 23016, 25000, 25001, 25002, 25003, 25004, 25005, 25006, 25007, 25008,
25009, 25010, 25011, 25012, 25013, 25014, 25015, 25018, 25019, 25020, 25021,
25022, 27000, 27004, 27306, 27307, 27311, 28000, 28001, 29200,
] as const;
export const clothesGearBrandGrouped = {
B00: [
2, 1000, 1006, 1022, 1023, 1030, 1035, 1052, 1053, 1062, 1075, 1076, 1077,
2008, 2010, 3000, 3001, 3002, 3003, 3005, 3013, 7001, 7002, 7006, 26000,
28000, 28001,
],
B01: [
1031, 1040, 1057, 1058, 1082, 1083, 2011, 2014, 2028, 3004, 4005, 5004,
5008, 6000, 6001, 8004,
],
B02: [
1007, 1008, 1041, 1051, 5016, 7016, 7017, 7020, 8001, 8018, 8024, 8030,
8034, 8042, 9004, 9005, 10012, 10014,
],
B03: [
1004, 1005, 1036, 1037, 1038, 1039, 1042, 1043, 2006, 2007, 5006, 5007,
8000, 8010, 8011,
],
B04: [
2001, 2004, 2018, 2019, 4004, 5003, 5014, 5015, 5024, 5025, 5026, 5028,
5034, 7005, 8002, 8006, 8009, 8012, 10002, 10009,
],
B05: [3009, 5010, 5011, 5012, 5013, 5020, 5021, 5035, 5038, 5048, 8005],
B06: [
1010, 1020, 1021, 1026, 1029, 1034, 1046, 2023, 2024, 7003, 8008, 9002,
9003, 10000, 10001,
],
B07: [
1009, 1011, 1012, 1017, 1027, 1048, 1055, 1084, 1085, 1086, 1087, 1088,
1089, 5065, 8027, 10006, 10007, 10010,
],
B08: [
1018, 1019, 2000, 2003, 2005, 2013, 2016, 4000, 4003, 5005, 5009, 5040,
5063, 7000, 7004, 8003, 8007, 8013, 8014, 8015, 8021, 8037, 8038, 10004,
10008,
],
B09: [
1015, 1016, 2009, 2012, 5000, 5002, 5017, 5019, 5027, 5039, 5051, 5052,
5054, 5060, 9000, 9001, 9007, 9010, 9014,
],
B10: [
1001, 1003, 1054, 1063, 1068, 1069, 1070, 3007, 3010, 3011, 4006, 6006,
9009, 9016,
],
B11: [
1013, 1014, 1049, 1050, 1078, 1079, 1081, 2002, 2021, 3006, 3008, 4001,
4002, 4007, 5001, 5018, 5061, 6007, 6008, 7015, 7018, 7019, 8041,
],
B15: [
1047, 1066, 1067, 2015, 2017, 2026, 2027, 5041, 5053, 5064, 7010, 7011,
7012, 8017, 8020, 8022, 8023, 8025,
],
B16: [
1033, 1056, 1059, 1060, 1095, 4008, 4009, 4010, 5033, 6003, 6004, 6005,
7013, 9011, 9012, 10005,
],
B17: [
1032, 5022, 5023, 5029, 5036, 5042, 5043, 5049, 5050, 5055, 5056, 7007,
7008, 7009, 8019, 8026, 8028, 8035, 8036, 9008, 9013, 9015,
],
B18: [1092, 1093, 7021, 7022, 7023, 8032, 8039, 8043, 10013, 10015],
B19: [1071, 1072, 1073, 1074, 1094, 5045, 5046, 5057, 5058, 5062],
B20: [1090, 1091, 5047, 5059, 7024, 7025, 8031, 8033, 8040, 9017],
B97: [
21000, 21001, 21002, 21003, 21004, 21005, 21006, 21008, 21009, 21010, 21011,
21012, 21013, 21014, 21015, 21016, 21017,
],
B98: [3, 1028, 2020, 2022, 7014, 27000, 27004, 27306, 27307, 27311, 29200],
B99: [
25000, 25001, 25002, 25003, 25004, 25005, 25006, 25007, 25008, 25009, 25010,
25011, 25012, 25013, 25014, 25015, 25017, 25018, 25019, 25020, 25021, 25022,
],
} as const;
export const clothesGearIds = Object.values(clothesGearBrandGrouped).flat();
export const shoesGearBrandGrouped = {
B00: [28000, 28001],
B01: [
1003, 1005, 1007, 1015, 1016, 1026, 1027, 2000, 2001, 2003, 2006, 2010,
2011, 2012, 3031, 4017, 4021, 4022,
],
B02: [
1, 1001, 1006, 2002, 2004, 2005, 2040, 4000, 4001, 4002, 4003, 4027, 4028,
7000, 7001, 7002, 7003, 23007, 23015,
],
B03: [
4014, 6000, 6001, 6002, 6003, 6006, 6007, 6008, 6013, 6014, 6017, 6019,
6020, 6025, 6028, 6030, 8000, 8001, 8002, 8003, 8004, 8005, 8006, 8007,
8008, 8009, 8014, 8015, 23002,
],
B04: [1000, 1002, 1004, 1017, 1018, 1019, 4008, 23000, 23004],
B05: [],
B06: [4011, 4012],
B07: [],
B08: [1008, 1009, 1014, 2008, 2009, 2013, 6012, 6018],
B09: [
1012, 1013, 3028, 3029, 4007, 4009, 4010, 4013, 5000, 5001, 5002, 6004,
6005, 6009,
],
B10: [
1021, 2014, 2015, 2019, 2023, 2024, 2029, 2030, 2031, 2032, 2033, 2037,
2043, 2044, 3000, 3002, 3004, 3005, 3006, 3007, 3009, 3010, 3011, 3012,
3014, 3018, 3020, 3021, 3022, 3030, 6010, 6011,
],
B11: [
1010, 1011, 2016, 2020, 2028, 2034, 2035, 2039, 2042, 3001, 3003, 3008,
3019, 3024, 4015, 4018, 23003, 23006,
],
B15: [4019, 6016, 6023, 6024, 8010, 8011, 8016],
B16: [
2017, 2018, 2022, 2025, 2026, 2027, 2038, 2050, 2051, 2053, 3015, 3016,
3017, 3026, 23001, 23005, 23008, 23009, 23010,
],
B17: [1020, 1023, 1025, 2036, 2045, 2046, 2052, 3013],
B18: [
2047, 2048, 4023, 4024, 4025, 4026, 6027, 7004, 23011, 23012, 23013, 23016,
],
B19: [1022, 1024, 2049, 6021, 6022, 6026, 6029, 8013, 23014],
B20: [3023, 3025, 3027, 4016, 4020],
B97: [21001, 21002, 21003, 21004, 21005, 21006, 21007, 21008, 21009],
B98: [27000, 27004, 27306, 27307, 27311, 29200],
B99: [
25000, 25001, 25002, 25003, 25004, 25005, 25006, 25007, 25008, 25009, 25010,
25011, 25012, 25013, 25014, 25015, 25018, 25019, 25020, 25021, 25022,
],
} as const;
export const shoesGearIds = Object.values(shoesGearBrandGrouped).flat();

View File

@ -1,4 +1,5 @@
import type { abilities } from "./abilities";
import type { brandIds } from "./brand-ids";
import type { modesShort, modesShortWithSpecial } from "./modes";
import type { stageIds } from "./stage-ids";
import type {
@ -48,3 +49,5 @@ export type BuildAbilitiesTupleWithUnknown = [
s3: AbilityWithUnknown,
],
];
export type BrandId = (typeof brandIds)[number];

View File

@ -1,6 +1,37 @@
import type { AnyWeapon } from "~/features/build-analyzer";
import {
allWeaponAltNames,
weaponAltNames,
} from "~/modules/in-game-lists/weapon-alt-names";
import { abilities } from "./abilities";
import type { Ability } from "./types";
export function isAbility(value: string): value is Ability {
return Boolean(abilities.some((a) => a.name === value));
}
const normalizeTerm = (term: string): string => {
return term.trim().toLocaleLowerCase();
};
export function filterWeapon({
weapon,
weaponName,
searchTerm,
}: {
weapon: AnyWeapon;
weaponName: string;
searchTerm: string;
}): boolean {
const normalizedSearchTerm = normalizeTerm(searchTerm);
const normalizedWeaponName = normalizeTerm(weaponName);
const isAlt = allWeaponAltNames.has(normalizedSearchTerm);
if (weapon.type === "MAIN" && isAlt) {
return (
weaponAltNames.get(weapon.id)?.includes(normalizedSearchTerm) ?? false
);
}
return normalizedWeaponName.includes(normalizedSearchTerm);
}

View File

@ -76,3 +76,9 @@ export const weaponAltNames = new Map<MainWeaponId, string[] | string>()
.set(8010, ["sword", "vwiper"])
.set(8011, ["sword", "diper", "dwiper"])
.set(8012, ["rust", "kwiper", "barazushi"]);
export const allWeaponAltNames = new Set(
Array.from(weaponAltNames.values()).flatMap((name) =>
typeof name === "string" ? [name] : name,
),
);

View File

@ -68,7 +68,6 @@ export default [
":id/report-winners",
"features/calendar/routes/calendar.$id.report-winners.tsx",
),
route("map-pool-events", "features/calendar/routes/map-pool-events.ts"),
]),
route("/calendar.ics", "features/calendar/routes/calendar.ics.tsx"),

View File

@ -417,83 +417,6 @@ abbr[title] {
font-size: var(--fonts-md);
}
.combobox-wrapper {
position: relative;
}
.combobox-input {
width: var(--input-width, 12rem);
}
.combobox-input.fullWidth {
width: 100%;
}
.combobox-options {
position: absolute;
z-index: 2;
width: var(--input-width, 12rem);
border-radius: var(--rounded);
margin-top: var(--s-2);
background-color: var(--bg-darker);
color: var(--text);
font-size: var(--fonts-sm);
padding-block: var(--s-3);
padding-inline: 0;
}
.combobox-options.fullWidth {
width: 100%;
}
.combobox-options.empty {
padding-block: var(--s-1-5);
}
.combobox-item {
display: flex;
align-items: center;
padding: var(--s-1) var(--s-3);
gap: var(--s-2);
list-style: none;
}
.combobox-item-image {
min-width: 24px;
}
.combobox-item-label {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
.combobox-no-matches {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-lighter);
font-size: var(--fonts-xs);
font-weight: var(--bold);
gap: var(--s-1);
}
.combobox-emoji {
color: var(--text);
font-size: var(--fonts-lg);
}
.combobox-item.active {
background-color: var(--theme-transparent);
}
.combobox-username {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article > p {
padding-block: var(--s-2-5);
}

View File

@ -6,10 +6,6 @@
gap: var(--s-6);
}
.countrySelect {
max-width: 10rem;
}
.inGameNameText {
max-width: 8rem;
}

View File

@ -222,6 +222,10 @@
padding-block-start: 0;
}
.pt-0-5-forced {
padding-block-start: var(--s-0-5) !important;
}
.pt-8-forced {
padding-block-start: var(--s-8) !important;
}
@ -235,7 +239,7 @@
}
.mt-0 {
margin-block-start: var(--s-0);
margin-block-start: 0;
}
.-mt-1px {

View File

@ -6,13 +6,18 @@ import { tournamentBracketsPage } from "./urls";
export async function selectWeapon({
page,
name,
inputName = "weapon",
testId = "weapon-select",
}: {
page: Page;
name: string;
inputName?: string;
testId?: string;
}) {
return selectComboboxValue({ page, value: name, inputName });
await page.getByTestId(testId).click();
await page.getByPlaceholder("Search weapons...").fill(name);
await page
.getByRole("listbox", { name: "Suggestions" })
.getByTestId(`weapon-select-option-${name}`)
.click();
}
export async function selectUser({
@ -38,26 +43,6 @@ export async function selectUser({
await page.keyboard.press("Enter");
}
export async function selectComboboxValue({
page,
value,
inputName,
locator,
}: {
page: Page;
value: string;
inputName?: string;
locator?: Locator;
}) {
if (!locator && !inputName) {
throw new Error("Must provide either locator or inputName");
}
const combobox = locator ?? page.getByTestId(`${inputName!}-combobox-input`);
await combobox.clear();
await combobox.fill(value);
await combobox.press("Enter");
}
/** page.goto that waits for the page to be hydrated before proceeding */
export async function navigate({ page, url }: { page: Page; url: string }) {
await page.goto(url);

View File

@ -4,10 +4,11 @@ import type { ArtSource } from "~/features/art/art-types";
import type { AuthErrorCode } from "~/features/auth/core/errors";
import { serializeBuild } from "~/features/build-analyzer";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import type { MapPool } from "~/features/map-list-generator/core/map-pool";
import type { StageBackgroundStyle } from "~/features/map-planner";
import type { TierName } from "~/features/mmr/mmr-constants";
import { JOIN_CODE_SEARCH_PARAM_KEY } from "~/features/sendouq/q-constants";
import type { ModeShort } from "~/modules/in-game-lists/types";
import type { BrandId, ModeShort } from "~/modules/in-game-lists/types";
import type {
Ability,
AbilityWithUnknown,
@ -139,7 +140,6 @@ export const THIRD_PLACEMENT_ICON_PATH =
export const soundPath = (fileName: string) =>
`/static-assets/sounds/${fileName}.wav`;
export const GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE = "/calendar/map-pool-events";
export const GET_TRUSTERS_ROUTE = "/trusters";
export const PATRONS_LIST_ROUTE = "/patrons-list";
@ -417,10 +417,8 @@ export const getWeaponUsage = ({
return `/weapon-usage?userId=${userId}&season=${season}&modeShort=${modeShort}&stageId=${stageId}`;
};
export const mapsPage = (eventId?: Tables["MapPoolMap"]["calendarEventId"]) =>
`/maps${eventId ? `?eventId=${eventId}` : ""}`;
export const readonlyMapsPage = (eventId: Tables["CalendarEvent"]["id"]) =>
`/maps?readonly&eventId=${eventId}`;
export const mapsPageWithMapPool = (mapPool: MapPool) =>
`/maps?readonly&pool=${mapPool.serialized}`;
export const articlePage = (slug: string) => `${ARTICLES_MAIN_PAGE}/${slug}`;
export const analyzerPage = (args?: {
weaponId: MainWeaponId;
@ -491,12 +489,12 @@ export const specialWeaponVariantImageUrl = (
`/static-assets/img/special-weapons/variants/${specialWeaponSplId}-${variant}`;
export const abilityImageUrl = (ability: AbilityWithUnknown) =>
`/static-assets/img/abilities/${ability}`;
export const brandImageUrl = (brand: BrandId) =>
`/static-assets/img/brands/${brand}`;
export const modeImageUrl = (mode: ModeShortWithSpecial) =>
`/static-assets/img/modes/${mode}`;
export const stageImageUrl = (stageId: StageId) =>
`/static-assets/img/stages/${stageId}`;
export const brandImageUrl = (brand: "tentatek" | "takoroka") =>
`/static-assets/img/layout/${brand}`;
export const tierImageUrl = (tier: TierName | "CALCULATING") =>
`/static-assets/img/tiers/${tier.toLowerCase()}`;
export const preferenceEmojiUrl = (preference?: Preference) => {

View File

@ -40,10 +40,8 @@ test.describe("Build Analyzer", () => {
// on new build page with preselected values
await newBuildPrompt.click();
await expect(page.getByTestId("HEAD-combobox-input")).toBeVisible();
await expect(page.getByTestId("weapon-combobox-input")).toHaveValue(
"Luna Blaster",
);
await expect(page.getByTestId("HEAD-gear-select")).toBeVisible();
await expect(page.getByTestId("weapon-0")).toContainText("Luna Blaster");
await page.getByTestId("SSU-ability").isVisible();
});

View File

@ -1,12 +1,8 @@
import { expect, test } from "@playwright/test";
import { type Page, expect, test } from "@playwright/test";
import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants";
import type { GearType } from "~/db/tables";
import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants";
import {
impersonate,
navigate,
seed,
selectComboboxValue,
} from "~/utils/playwright";
import { impersonate, navigate, seed, selectWeapon } from "~/utils/playwright";
import { BUILDS_PAGE, userBuildsPage, userNewBuildPage } from "~/utils/urls";
test.describe("Builds", () => {
@ -18,31 +14,31 @@ test.describe("Builds", () => {
url: userNewBuildPage({ discordId: NZAP_TEST_DISCORD_ID }),
});
await selectComboboxValue({
locator: page.getByTestId("weapon-combobox-input").first(),
value: "Tenta Brella",
await selectWeapon({
testId: "weapon-0",
name: "Tenta Brella",
page,
});
await page.getByTestId("add-weapon-button").click();
await selectComboboxValue({
locator: page.getByTestId("weapon-combobox-input").nth(1),
value: "Splat Brella",
await selectWeapon({
testId: "weapon-1",
name: "Splat Brella",
page,
});
await selectComboboxValue({
inputName: "HEAD",
value: "White Headband",
await selectGear({
type: "HEAD",
name: "White Headband",
page,
});
await selectComboboxValue({
inputName: "CLOTHES",
value: "Basic Tee",
await selectGear({
type: "CLOTHES",
name: "Basic Tee",
page,
});
await selectComboboxValue({
inputName: "SHOES",
value: "Blue Lo-Tops",
await selectGear({
type: "SHOES",
name: "Blue Lo-Tops",
page,
});
@ -144,3 +140,20 @@ test.describe("Builds", () => {
await expect(page.getByTestId("build-card")).toHaveCount(24);
});
});
async function selectGear({
page,
name,
type,
}: {
page: Page;
name: string;
type: GearType;
}) {
await page.getByTestId(`${type}-gear-select`).click();
await page.getByPlaceholder("Search gear...").fill(name);
await page
.getByRole("listbox", { name: "Suggestions" })
.getByTestId(`gear-select-option-${name}`)
.click();
}

View File

@ -52,7 +52,7 @@ test.describe("VoDs page", () => {
await selectWeapon({
name: "Zink Mini Splatling",
page,
inputName: "match-0-weapon",
testId: "match-0-weapon",
});
await page.getByTestId("add-field-button").click();
@ -63,7 +63,7 @@ test.describe("VoDs page", () => {
await selectWeapon({
name: "Tenta Brella",
page,
inputName: "match-1-weapon",
testId: "match-1-weapon",
});
await submit(page);
@ -105,7 +105,7 @@ test.describe("VoDs page", () => {
await selectWeapon({
name: i < 4 ? "Luna Blaster" : "Tenta Brella",
page,
inputName: `player-${i}-weapon`,
testId: `player-${i}-weapon`,
});
}
@ -131,7 +131,7 @@ test.describe("VoDs page", () => {
await selectWeapon({
name: "Luna Blaster",
page,
inputName: "match-3-weapon",
testId: "match-3-weapon",
});
await submit(page);
@ -149,8 +149,10 @@ test.describe("VoDs page", () => {
url: VODS_PAGE,
});
await page.getByText("N-ZAP").isVisible();
const nzapUserPageLink = page.getByRole("link", { name: "N-ZAP" });
await nzapUserPageLink.isVisible();
await selectWeapon({ page, name: "Carbon Roller" });
await isNotVisible(page.getByText("N-ZAP"));
await isNotVisible(nzapUserPageLink);
});
});

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "Vægt",
"attribute.weight.Fast": "Let",
"attribute.weight.Slow": "Tung",

View File

@ -23,7 +23,8 @@
"forms.tags.cantFindExisting": "Kan du ikke finde en eksisterende etiket?",
"forms.tags.addNew": "Lav en ny en",
"forms.tags.addNew.placeholder": "Lav en ny etiket",
"forms.tags.searchExisting.placeholder": "Søg i eksisterende etiketter",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Maks antal etiketter opnået",
"delete.title": "",
"unlink.title": ""

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "Tiebreak-banepulje",
"maps.template": "Skabelon",
"maps.template.none": "Ingen",
"maps.template.event": "Begivenhed",
"maps.template.presets": "Forudindstillet",
"maps.template.yourRecentEvents": "Nylige begivenheder",
"maps.template.preset.ANARCHY": "Anarchy-spiltilstande",
@ -156,7 +155,10 @@
"forms.errors.noSearchMatches": "Ingen resultater fundet",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"errors.genericReload": "Noget gik galt. Prøv at genindlæse siden.",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "Særregler",
"tag.name.ART": "Kunstpræmier",
"tag.name.MONEY": "Pengepræmier",

View File

@ -64,5 +64,26 @@
"MODE_LONG_TB": "",
"GAME_S1": "",
"GAME_S2": "",
"GAME_S3": ""
"GAME_S3": "",
"BRAND_B00": "SquidForce",
"BRAND_B01": "Zink",
"BRAND_B02": "Krak-On",
"BRAND_B03": "Rockenberg",
"BRAND_B04": "Zekko",
"BRAND_B05": "Forge",
"BRAND_B06": "Firefin",
"BRAND_B07": "Skalop",
"BRAND_B08": "Splash Mob",
"BRAND_B09": "Inkline",
"BRAND_B10": "Tentatek",
"BRAND_B11": "Takoroka",
"BRAND_B15": "Annaki",
"BRAND_B16": "Enperry",
"BRAND_B17": "Toni Kensa",
"BRAND_B18": "Z+F",
"BRAND_B19": "Barazushi",
"BRAND_B20": "Emberz",
"BRAND_B97": "Grizzco",
"BRAND_B98": "Cuttlegear",
"BRAND_B99": "amiibo"
}

View File

@ -35,6 +35,5 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
"banned.banModal.noteHelp": ""
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "Gewicht",
"attribute.weight.Fast": "Leicht",
"attribute.weight.Slow": "Schwer",

View File

@ -23,7 +23,8 @@
"forms.tags.cantFindExisting": "",
"forms.tags.addNew": "",
"forms.tags.addNew.placeholder": "",
"forms.tags.searchExisting.placeholder": "",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": ""

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "Arenen-Pool für Tie-Breaker",
"maps.template": "Vorlage",
"maps.template.none": "Keine",
"maps.template.event": "Event",
"maps.template.presets": "Voreinstellungen",
"maps.template.yourRecentEvents": "Deine Events",
"maps.template.preset.ANARCHY": "Anarchie-Modi",
@ -156,7 +155,10 @@
"forms.errors.noSearchMatches": "Keine Suchergebnisse",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"errors.genericReload": "Etwas ist schiefgegangen. Versuche die Seite neu zu laden.",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "Spezielle Regeln",
"tag.name.ART": "Kunst-Preise",
"tag.name.MONEY": "Geld-Preise",

View File

@ -64,5 +64,26 @@
"MODE_LONG_TB": "",
"GAME_S1": "",
"GAME_S2": "",
"GAME_S3": ""
"GAME_S3": "",
"BRAND_B00": "TintactiX",
"BRAND_B01": "Sagitron",
"BRAND_B02": "Medusa",
"BRAND_B03": "Rockberg",
"BRAND_B04": "Zekko",
"BRAND_B05": "Focus",
"BRAND_B06": "Rilax",
"BRAND_B07": "Jakomu",
"BRAND_B08": "Gian",
"BRAND_B09": "Alpomar",
"BRAND_B10": "Tentatek",
"BRAND_B11": "Kalamati",
"BRAND_B15": "Annaki",
"BRAND_B16": "Enperry",
"BRAND_B17": "Toni Kensa",
"BRAND_B18": "Zekkori",
"BRAND_B19": "Barazushi",
"BRAND_B20": "Shichiryn",
"BRAND_B97": "Bär GmbH",
"BRAND_B98": "Kuttel-Korps",
"BRAND_B99": "amiibo"
}

View File

@ -35,6 +35,5 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
"banned.banModal.noteHelp": ""
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "Weapon to analyze",
"attribute.weight": "Weight",
"attribute.weight.Fast": "Light",
"attribute.weight.Slow": "Heavy",

View File

@ -23,7 +23,8 @@
"forms.tags.cantFindExisting": "Can't find an existing tag?",
"forms.tags.addNew": "Create a new one.",
"forms.tags.addNew.placeholder": "Create a new tag",
"forms.tags.searchExisting.placeholder": "Search existing tags",
"forms.tags.search.placeholder": "Search tags...",
"forms.tags.placeholder": "Select a tag",
"forms.tags.maxReached": "Max tags reached",
"delete.title": "Are you sure you want to delete the art?",
"unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?"

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "Tiebreaker map pool",
"maps.template": "Template",
"maps.template.none": "None",
"maps.template.event": "Event",
"maps.template.presets": "Presets",
"maps.template.yourRecentEvents": "Recent Events",
"maps.template.preset.ANARCHY": "Anarchy Modes",
@ -156,7 +155,10 @@
"forms.errors.noSearchMatches": "No matches found",
"forms.userSearch.placeholder": "Search users by username, profile URL or Discord ID...",
"forms.userSearch.noResults": "No users matching your search found",
"errors.genericReload": "Something went wrong. Try reloading the page.",
"forms.weaponSearch.placeholder": "Select a weapon",
"forms.weaponSearch.search.placeholder": "Search weapons...",
"forms.gearSearch.placeholder": "Select a gear",
"forms.gearSearch.search.placeholder": "Search gear...",
"tag.name.SPECIAL": "Special rules",
"tag.name.ART": "Art prizes",
"tag.name.MONEY": "Money prizes",

View File

@ -64,5 +64,26 @@
"MODE_LONG_TB": "Tableturf Battle",
"GAME_S1": "Splatoon 1",
"GAME_S2": "Splatoon 2",
"GAME_S3": "Splatoon 3"
"GAME_S3": "Splatoon 3",
"BRAND_B00": "SquidForce",
"BRAND_B01": "Zink",
"BRAND_B02": "Krak-On",
"BRAND_B03": "Rockenberg",
"BRAND_B04": "Zekko",
"BRAND_B05": "Forge",
"BRAND_B06": "Firefin",
"BRAND_B07": "Skalop",
"BRAND_B08": "Splash Mob",
"BRAND_B09": "Inkline",
"BRAND_B10": "Tentatek",
"BRAND_B11": "Takoroka",
"BRAND_B15": "Annaki",
"BRAND_B16": "Enperry",
"BRAND_B17": "Toni Kensa",
"BRAND_B18": "Z+F",
"BRAND_B19": "Barazushi",
"BRAND_B20": "Emberz",
"BRAND_B97": "Grizzco",
"BRAND_B98": "Cuttlegear",
"BRAND_B99": "amiibo"
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "Peso",
"attribute.weight.Fast": "Ligero",
"attribute.weight.Slow": "Pesado",

View File

@ -24,7 +24,8 @@
"forms.tags.cantFindExisting": "¿No eucuentras una etiqueta existente?",
"forms.tags.addNew": "Crear nueva.",
"forms.tags.addNew.placeholder": "Crear una nueva etiqueta",
"forms.tags.searchExisting.placeholder": "Buscar etiquetas existentes",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Limite máximo de etiquetas",
"delete.title": "",
"unlink.title": ""

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "Grupo de mapas para desempate",
"maps.template": "Plantilla",
"maps.template.none": "Ninguno",
"maps.template.event": "Evento",
"maps.template.presets": "Presets",
"maps.template.yourRecentEvents": "Eventos recientes",
"maps.template.preset.ANARCHY": "Modos de combate caótico",
@ -157,7 +156,10 @@
"forms.errors.noSearchMatches": "No se encuentran partidos",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"errors.genericReload": "Algo salió mal. Intente recargar la página.",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "Reglas especiales",
"tag.name.ART": "Premios de arte",
"tag.name.MONEY": "Premios de dinero",

View File

@ -64,5 +64,26 @@
"MODE_LONG_TB": "",
"GAME_S1": "",
"GAME_S2": "",
"GAME_S3": ""
"GAME_S3": "",
"BRAND_B00": "Kalamas",
"BRAND_B01": "Tintaz",
"BRAND_B02": "Meddux",
"BRAND_B03": "Rockenberg",
"BRAND_B04": "Ezko",
"BRAND_B05": "Forima",
"BRAND_B06": "Chokkor",
"BRAND_B07": "Skalop",
"BRAND_B08": "Jimmon",
"BRAND_B09": "Moluskia",
"BRAND_B10": "Arome",
"BRAND_B11": "Sepioca",
"BRAND_B15": "Annak",
"BRAND_B16": "Krakien",
"BRAND_B17": "Tonken",
"BRAND_B18": "Ezkkor",
"BRAND_B19": "Barazushi",
"BRAND_B20": "Ascuax",
"BRAND_B97": "Don Oso",
"BRAND_B98": "Obvius",
"BRAND_B99": "amiibo"
}

View File

@ -35,6 +35,5 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
"banned.banModal.noteHelp": ""
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "Peso",
"attribute.weight.Fast": "Ligero",
"attribute.weight.Slow": "Pesado",

View File

@ -24,7 +24,8 @@
"forms.tags.cantFindExisting": "¿No eucuentras una etiqueta existente?",
"forms.tags.addNew": "Crear nueva.",
"forms.tags.addNew.placeholder": "Crear una nueva etiqueta",
"forms.tags.searchExisting.placeholder": "Buscar etiquetas existentes",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Limite máximo de etiquetas",
"delete.title": "",
"unlink.title": ""

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "Grupo de escenarios para desempate",
"maps.template": "Plantilla",
"maps.template.none": "Ninguno",
"maps.template.event": "Evento",
"maps.template.presets": "Preajustes",
"maps.template.yourRecentEvents": "Eventos recientes",
"maps.template.preset.ANARCHY": "Estilos de combate caótico",
@ -157,7 +156,10 @@
"forms.errors.noSearchMatches": "No se encuentran partidos",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"errors.genericReload": "Algo salió mal. Intente recargar la página.",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "Reglas especiales",
"tag.name.ART": "Premios de arte",
"tag.name.MONEY": "Premios de dinero",

View File

@ -64,5 +64,26 @@
"MODE_LONG_TB": "",
"GAME_S1": "",
"GAME_S2": "",
"GAME_S3": ""
"GAME_S3": "",
"BRAND_B00": "Kalamas",
"BRAND_B01": "Tintaz",
"BRAND_B02": "Meddux",
"BRAND_B03": "Rockenberg",
"BRAND_B04": "Zekko",
"BRAND_B05": "Forima",
"BRAND_B06": "Shachi",
"BRAND_B07": "Skalop",
"BRAND_B08": "Tinta comunal",
"BRAND_B09": "Moluskia",
"BRAND_B10": "Tentáctica",
"BRAND_B11": "Pulpioka",
"BRAND_B15": "Annak",
"BRAND_B16": "Emperi",
"BRAND_B17": "Tonken",
"BRAND_B18": "ZyS",
"BRAND_B19": "Barazushi",
"BRAND_B20": "Ascuax",
"BRAND_B97": "Don Oso",
"BRAND_B98": "Sepichón",
"BRAND_B99": "amiibo"
}

View File

@ -35,6 +35,5 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
"banned.banModal.noteHelp": ""
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "Poids",
"attribute.weight.Fast": "Léger",
"attribute.weight.Slow": "Lourd",

View File

@ -24,7 +24,8 @@
"forms.tags.cantFindExisting": "Impossible de trouver un tag existant ?",
"forms.tags.addNew": "En créer un nouveau.",
"forms.tags.addNew.placeholder": "Créer un nouveau tag",
"forms.tags.searchExisting.placeholder": "Chercher des tags existants",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Limite de tags atteinte",
"delete.title": "",
"unlink.title": ""

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "Pool de stages de tie breaker",
"maps.template": "Modèle",
"maps.template.none": "Aucun",
"maps.template.event": "Événement",
"maps.template.presets": "Préréglages",
"maps.template.yourRecentEvents": "Événements récents",
"maps.template.preset.ANARCHY": "Modes Anarchie",
@ -157,7 +156,10 @@
"forms.errors.noSearchMatches": "Pas de correspondance trouvée",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "Règles spéciales",
"tag.name.ART": "Illustration à gagner",
"tag.name.MONEY": "Argent à gagner",

View File

@ -64,5 +64,26 @@
"MODE_LONG_TB": "",
"GAME_S1": "",
"GAME_S2": "",
"GAME_S3": ""
"GAME_S3": "",
"BRAND_B00": "Kalmarus",
"BRAND_B01": "Leviathus",
"BRAND_B02": "Krakeurs",
"BRAND_B03": "Iormungand",
"BRAND_B04": "Ezko",
"BRAND_B05": "Fosia",
"BRAND_B06": "Arki",
"BRAND_B07": "P. Tonk",
"BRAND_B08": "Fréma",
"BRAND_B09": "Abysma",
"BRAND_B10": "Tentatek",
"BRAND_B11": "Pionic",
"BRAND_B15": "Aroz",
"BRAND_B16": "DUX",
"BRAND_B17": "Thony K",
"BRAND_B18": "E+A",
"BRAND_B19": "Alpaj",
"BRAND_B20": "Tizonius",
"BRAND_B97": "M. Ours Cie",
"BRAND_B98": "Radmaré",
"BRAND_B99": "amiibo"
}

View File

@ -35,6 +35,5 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
"banned.banModal.noteHelp": ""
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "Poids",
"attribute.weight.Fast": "Léger",
"attribute.weight.Slow": "Lourd",

View File

@ -24,7 +24,8 @@
"forms.tags.cantFindExisting": "Impossible de trouver un tag existant ?",
"forms.tags.addNew": "En créer un nouveau.",
"forms.tags.addNew.placeholder": "Créer un nouveau tag",
"forms.tags.searchExisting.placeholder": "Chercher des tags existants",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Limite de tags atteinte",
"delete.title": "Êtes vous sûre de vouloir supprimer cette œuvre?",
"unlink.title": "Êtes vous sûre de vouloir supprimer cette œuvre de votre profil? (Seulement {{username}} pourra le remettre)"

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "Pool de stages de tie breaker",
"maps.template": "Modèle",
"maps.template.none": "Aucun",
"maps.template.event": "Événement",
"maps.template.presets": "Préréglages",
"maps.template.yourRecentEvents": "Événements récents",
"maps.template.preset.ANARCHY": "Modes Anarchie",
@ -157,7 +156,10 @@
"forms.errors.noSearchMatches": "Pas de correspondance trouvée",
"forms.userSearch.placeholder": "Cherchez les utilisateur par leur pseudo, l'URL de leur profil ou l'ID discord...",
"forms.userSearch.noResults": "Aucun utilisateur trouvé",
"errors.genericReload": "Quelque chose s'est mal passé. Essayez d'actualiser la page.",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "Règles spéciales",
"tag.name.ART": "Illustration à gagner",
"tag.name.MONEY": "Argent à gagner",

View File

@ -64,5 +64,26 @@
"MODE_LONG_TB": "",
"GAME_S1": "",
"GAME_S2": "",
"GAME_S3": ""
"GAME_S3": "",
"BRAND_B00": "Kalamarus Rex",
"BRAND_B01": "Leviathus",
"BRAND_B02": "Krakers",
"BRAND_B03": "Iormungand",
"BRAND_B04": "Ezko",
"BRAND_B05": "Focus",
"BRAND_B06": "Friture",
"BRAND_B07": "Jack",
"BRAND_B08": "Gédéon",
"BRAND_B09": "Abysma",
"BRAND_B10": "OculR",
"BRAND_B11": "Cubic",
"BRAND_B15": "Aroz",
"BRAND_B16": "DUX",
"BRAND_B17": "Thony K",
"BRAND_B18": "E+F",
"BRAND_B19": "Alpaj",
"BRAND_B20": "Apex",
"BRAND_B97": "M. Ours SA",
"BRAND_B98": "Macalamar",
"BRAND_B99": "amiibo"
}

View File

@ -35,6 +35,5 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
"banned.banModal.noteHelp": ""
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "משקל",
"attribute.weight.Fast": "קל",
"attribute.weight.Slow": "כבד",

View File

@ -24,7 +24,8 @@
"forms.tags.cantFindExisting": "לא מוצאים תג קיים?",
"forms.tags.addNew": "יצירת חדש.",
"forms.tags.addNew.placeholder": "יצירת תג חדש",
"forms.tags.searchExisting.placeholder": "חיפוש תגים קיימים",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "הגעתם לכמות תגים מקסימלית",
"delete.title": "",
"unlink.title": ""

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "מאגר מפות לשובר שיוויון",
"maps.template": "תבנית",
"maps.template.none": "ללא",
"maps.template.event": "אירוע",
"maps.template.presets": "הגדרות קבועות מראש",
"maps.template.yourRecentEvents": "אירועים אחרונים",
"maps.template.preset.ANARCHY": "מודים של אנרכיה",
@ -156,7 +155,10 @@
"forms.errors.noSearchMatches": "לא נמצאה התאמה",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"errors.genericReload": "משהו השתבש. נסו לטעון מחדש את העמוד.",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "חוקים מיוחדים",
"tag.name.ART": "פרסי ציור",
"tag.name.MONEY": "פרסים בכסף",

View File

@ -60,9 +60,30 @@
"MODE_LONG_TC": "Tower Control",
"MODE_LONG_RM": "Rainmaker",
"MODE_LONG_CB": "Clam Blitz",
"MODE_LONG_SR": "",
"MODE_LONG_TB": "",
"GAME_S1": "",
"GAME_S2": "",
"GAME_S3": ""
"MODE_LONG_SR": "Salmon Run",
"MODE_LONG_TB": "Tableturf Battle",
"GAME_S1": "Splatoon 1",
"GAME_S2": "Splatoon 2",
"GAME_S3": "Splatoon 3",
"BRAND_B00": "SquidForce",
"BRAND_B01": "Zink",
"BRAND_B02": "Krak-On",
"BRAND_B03": "Rockenberg",
"BRAND_B04": "Zekko",
"BRAND_B05": "Forge",
"BRAND_B06": "Firefin",
"BRAND_B07": "Skalop",
"BRAND_B08": "Splash Mob",
"BRAND_B09": "Inkline",
"BRAND_B10": "Tentatek",
"BRAND_B11": "Takoroka",
"BRAND_B15": "Annaki",
"BRAND_B16": "Enperry",
"BRAND_B17": "Toni Kensa",
"BRAND_B18": "Z+F",
"BRAND_B19": "Barazushi",
"BRAND_B20": "Emberz",
"BRAND_B97": "Grizzco",
"BRAND_B98": "Cuttlegear",
"BRAND_B99": "amiibo"
}

View File

@ -35,6 +35,5 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
"banned.banModal.noteHelp": ""
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "Peso",
"attribute.weight.Fast": "Leggero",
"attribute.weight.Slow": "Pesante",

View File

@ -24,7 +24,8 @@
"forms.tags.cantFindExisting": "Non riesci a trovare un tag esistente?",
"forms.tags.addNew": "Creane uno nuovo.",
"forms.tags.addNew.placeholder": "Crea un nuovo tag",
"forms.tags.searchExisting.placeholder": "Cerca tag esistenti",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "Numero massimo di tag raggiunto.",
"delete.title": "",
"unlink.title": ""

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "Pool scenari per lo spareggio",
"maps.template": "Template",
"maps.template.none": "Nessuno",
"maps.template.event": "Evento",
"maps.template.presets": "Preset",
"maps.template.yourRecentEvents": "Eventi Recenti",
"maps.template.preset.ANARCHY": "Modalità anarchia",
@ -157,7 +156,10 @@
"forms.errors.noSearchMatches": "Nessun risultato trovato.",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"errors.genericReload": "Qualcosa è andato storto. Prova a ricaricare la pagina.",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "Regole speciali",
"tag.name.ART": "Premi artistici",
"tag.name.MONEY": "Premi monetari",

View File

@ -64,5 +64,26 @@
"MODE_LONG_TB": "",
"GAME_S1": "",
"GAME_S2": "",
"GAME_S3": ""
"GAME_S3": "",
"BRAND_B00": "CalamaX",
"BRAND_B01": "Totanic",
"BRAND_B02": "Cnidarya",
"BRAND_B03": "Rockenburg",
"BRAND_B04": "Ezko",
"BRAND_B05": "Fortia",
"BRAND_B06": "Torryd",
"BRAND_B07": "Capasaint",
"BRAND_B08": "Chimmon",
"BRAND_B09": "Maremont",
"BRAND_B10": "Arome",
"BRAND_B11": "Sepia",
"BRAND_B15": "Annak",
"BRAND_B16": "Imperial",
"BRAND_B17": "Nero Marlin",
"BRAND_B18": "E+T",
"BRAND_B19": "BaraS",
"BRAND_B20": "Car-bon",
"BRAND_B97": "Ursus & Co",
"BRAND_B98": "Krakensen",
"BRAND_B99": "amiibo"
}

View File

@ -35,6 +35,5 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
"banned.banModal.noteHelp": ""
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "重さ",
"attribute.weight.Fast": "軽量級",
"attribute.weight.Slow": "重量級",

View File

@ -21,7 +21,8 @@
"forms.tags.cantFindExisting": "タグが見つからない場合:",
"forms.tags.addNew": "新規作成してください。",
"forms.tags.addNew.placeholder": "新しいタグを作成",
"forms.tags.searchExisting.placeholder": "既存のタグを検索する",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "タグの最大数に到達しました",
"delete.title": "",
"unlink.title": ""

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "タイブレークマップリスト",
"maps.template": "テンプレート",
"maps.template.none": "なし",
"maps.template.event": "イベント",
"maps.template.presets": "プリセット",
"maps.template.yourRecentEvents": "最近のイベント",
"maps.template.preset.ANARCHY": "バンカラマッチルール",
@ -154,7 +153,10 @@
"forms.errors.noSearchMatches": "検索結果がみつかりませんでした",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"errors.genericReload": "エラーが発生しました。ページを再読込してください。",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "特別ルール",
"tag.name.ART": "イラスト賞品",
"tag.name.MONEY": "賞金",

View File

@ -64,5 +64,26 @@
"MODE_LONG_TB": "",
"GAME_S1": "",
"GAME_S2": "",
"GAME_S3": ""
"GAME_S3": "",
"BRAND_B00": "バトロイカ",
"BRAND_B01": "アイロニック",
"BRAND_B02": "クラーゲス",
"BRAND_B03": "ロッケンベルグ",
"BRAND_B04": "エゾッコ",
"BRAND_B05": "フォーリマ",
"BRAND_B06": "ホッコリー",
"BRAND_B07": "ホタックス",
"BRAND_B08": "ジモン",
"BRAND_B09": "シグレニ",
"BRAND_B10": "アロメ",
"BRAND_B11": "ヤコ",
"BRAND_B15": "アナアキ",
"BRAND_B16": "エンペリー",
"BRAND_B17": "タタキケンサキ",
"BRAND_B18": "エゾッコリー",
"BRAND_B19": "バラズシ",
"BRAND_B20": "シチリン",
"BRAND_B97": "クマサン商会",
"BRAND_B98": "アタリメイド",
"BRAND_B99": "amiibo"
}

View File

@ -35,6 +35,5 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": "",
"banned.banModal.explanation": ""
"banned.banModal.noteHelp": ""
}

View File

@ -1,4 +1,5 @@
{
"weaponSelect.label": "",
"attribute.weight": "무게",
"attribute.weight.Fast": "가벼움",
"attribute.weight.Slow": "무거움",

View File

@ -21,7 +21,8 @@
"forms.tags.cantFindExisting": "",
"forms.tags.addNew": "",
"forms.tags.addNew.placeholder": "",
"forms.tags.searchExisting.placeholder": "",
"forms.tags.search.placeholder": "",
"forms.tags.placeholder": "",
"forms.tags.maxReached": "",
"delete.title": "",
"unlink.title": ""

View File

@ -130,7 +130,6 @@
"maps.tieBreakerMapPool": "타이브레이커 맵 풀",
"maps.template": "템플릿",
"maps.template.none": "없음",
"maps.template.event": "이벤트",
"maps.template.presets": "프리셋",
"maps.template.yourRecentEvents": "최근 이벤트",
"maps.template.preset.ANARCHY": "랭크 룰",
@ -154,7 +153,10 @@
"forms.errors.noSearchMatches": "검색 결과 없음",
"forms.userSearch.placeholder": "",
"forms.userSearch.noResults": "",
"errors.genericReload": "뭔가 잘못됐습니다 페이지를 새로고침해보세요.",
"forms.weaponSearch.placeholder": "",
"forms.weaponSearch.search.placeholder": "",
"forms.gearSearch.placeholder": "",
"forms.gearSearch.search.placeholder": "",
"tag.name.SPECIAL": "특별 규칙",
"tag.name.ART": "그림 상품",
"tag.name.MONEY": "상금",

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