mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-24 12:06:54 -05:00
Merge pull request #1047 from Sendouc/map-pool-templates
Map pool templates
This commit is contained in:
@@ -4,7 +4,7 @@ import Fuse from "fuse.js";
|
||||
import clsx from "clsx";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import type { GearType, UserWithPlusTier } from "~/db/types";
|
||||
import { useUsers } from "~/hooks/swr";
|
||||
import { useAllEventsWithMapPools, useUsers } from "~/hooks/swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
clothesGearIds,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "~/modules/in-game-lists";
|
||||
import { gearImageUrl, mainWeaponImageUrl } from "~/utils/urls";
|
||||
import { Image } from "./Image";
|
||||
import { type SerializedMapPoolEvent } from "~/routes/calendar/map-pool-events";
|
||||
|
||||
const MAX_RESULTS_SHOWN = 6;
|
||||
|
||||
@@ -36,6 +37,7 @@ interface ComboboxProps<T> {
|
||||
initialValue?: ComboboxOption<T>;
|
||||
clearsInputOnFocus?: boolean;
|
||||
onChange?: (selectedOption?: ComboboxOption<T>) => void;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function Combobox<T extends Record<string, string | null | number>>({
|
||||
@@ -49,11 +51,16 @@ export function Combobox<T extends Record<string, string | null | number>>({
|
||||
className,
|
||||
id,
|
||||
isLoading = false,
|
||||
fullWidth = false,
|
||||
}: ComboboxProps<T>) {
|
||||
const [selectedOption, setSelectedOption] =
|
||||
React.useState<Unpacked<typeof options>>();
|
||||
const [lastSelectedOption, setLastSelectedOption] =
|
||||
React.useState<Unpacked<typeof options>>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedOption, setSelectedOption] = React.useState<
|
||||
Unpacked<typeof options> | undefined
|
||||
>(initialValue);
|
||||
const [lastSelectedOption, setLastSelectedOption] = React.useState<
|
||||
Unpacked<typeof options> | undefined
|
||||
>(initialValue);
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -75,73 +82,85 @@ export function Combobox<T extends Record<string, string | null | number>>({
|
||||
|
||||
const noMatches = filteredOptions.length === 0;
|
||||
|
||||
const displayValue = (option: Unpacked<typeof options>) => {
|
||||
return option?.label ?? "";
|
||||
};
|
||||
|
||||
return (
|
||||
<HeadlessCombobox
|
||||
value={selectedOption}
|
||||
onChange={(selected) => {
|
||||
onChange?.(selected);
|
||||
setSelectedOption(selected);
|
||||
setLastSelectedOption(selected);
|
||||
}}
|
||||
name={inputName}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<HeadlessCombobox.Input
|
||||
onFocus={() => {
|
||||
if (clearsInputOnFocus) {
|
||||
setSelectedOption(undefined);
|
||||
}
|
||||
<div className="combobox-wrapper">
|
||||
<HeadlessCombobox
|
||||
value={selectedOption}
|
||||
onChange={(selected) => {
|
||||
onChange?.(selected);
|
||||
setSelectedOption(selected);
|
||||
setLastSelectedOption(selected);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!selectedOption && clearsInputOnFocus) {
|
||||
setSelectedOption(lastSelectedOption);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={isLoading ? "Loading..." : placeholder}
|
||||
className={clsx("combobox-input", className)}
|
||||
displayValue={(option) =>
|
||||
(option as unknown as Unpacked<typeof options>)?.label ?? ""
|
||||
}
|
||||
data-cy={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
required={required}
|
||||
/>
|
||||
<HeadlessCombobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
empty: noMatches,
|
||||
hidden: !query,
|
||||
})}
|
||||
name={inputName}
|
||||
disabled={!selectedOption && isLoading}
|
||||
>
|
||||
{noMatches ? (
|
||||
<div className="combobox-no-matches">
|
||||
No matches found <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}
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</li>
|
||||
)}
|
||||
</HeadlessCombobox.Option>
|
||||
))
|
||||
)}
|
||||
</HeadlessCombobox.Options>
|
||||
</HeadlessCombobox>
|
||||
<HeadlessCombobox.Input
|
||||
onFocus={() => {
|
||||
if (clearsInputOnFocus) {
|
||||
setSelectedOption(undefined);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!selectedOption && clearsInputOnFocus) {
|
||||
setSelectedOption(lastSelectedOption);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={isLoading ? t("actions.loading") : placeholder}
|
||||
className={clsx("combobox-input", className, {
|
||||
fullWidth,
|
||||
})}
|
||||
// To make SSR prefill work in an uncontrolled component
|
||||
defaultValue={initialValue ? displayValue(initialValue) : undefined}
|
||||
displayValue={displayValue}
|
||||
data-cy={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
required={required}
|
||||
/>
|
||||
<HeadlessCombobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
empty: noMatches,
|
||||
fullWidth,
|
||||
hidden: !query,
|
||||
})}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</li>
|
||||
)}
|
||||
</HeadlessCombobox.Option>
|
||||
))
|
||||
)}
|
||||
</HeadlessCombobox.Options>
|
||||
</HeadlessCombobox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,6 +176,7 @@ export function UserCombobox({
|
||||
ComboboxProps<Pick<UserWithPlusTier, "discordId" | "plusTier">>,
|
||||
"inputName" | "onChange" | "className" | "id" | "required"
|
||||
> & { userIdsToOmit?: Set<number>; initialUserId?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { users, isLoading, isError } = useUsers();
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
@@ -181,9 +201,7 @@ export function UserCombobox({
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-sm text-error">
|
||||
Something went wrong. Try reloading the page.
|
||||
</div>
|
||||
<div className="text-sm text-error">{t("errors.genericReload")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +209,7 @@ export function UserCombobox({
|
||||
<Combobox
|
||||
inputName={inputName}
|
||||
options={options}
|
||||
placeholder="Sendou#0043"
|
||||
placeholder="Sendou#4059"
|
||||
isLoading={isLoading}
|
||||
initialValue={initialValue}
|
||||
onChange={onChange}
|
||||
@@ -288,3 +306,71 @@ export function GearCombobox({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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) => 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}
|
||||
onChange={(e) => {
|
||||
onChange(
|
||||
e && {
|
||||
id: parseInt(e.value, 10),
|
||||
name: e.label,
|
||||
serializedMapPool: e.serializedMapPool,
|
||||
}
|
||||
);
|
||||
}}
|
||||
className={className}
|
||||
id={id}
|
||||
required={required}
|
||||
isLoading={isLoading}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
399
app/components/MapPoolSelector.tsx
Normal file
399
app/components/MapPoolSelector.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "~/components/Image";
|
||||
import {
|
||||
type ModeShort,
|
||||
modesShort,
|
||||
type StageId,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { modes, stageIds } from "~/modules/in-game-lists";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
||||
import { Button } from "~/components/Button";
|
||||
import { split, startsWith } from "~/utils/strings";
|
||||
import { CrossIcon } from "./icons/Cross";
|
||||
import { ArrowLongLeftIcon } from "./icons/ArrowLongLeft";
|
||||
import * as React from "react";
|
||||
import type { CalendarEvent } from "~/db/types";
|
||||
import type { SerializedMapPoolEvent } from "~/routes/calendar/map-pool-events";
|
||||
import { assertType } from "~/utils/types";
|
||||
import { MapPoolEventsCombobox } from "./Combobox";
|
||||
import { useOnce } from "~/hooks/useOnce";
|
||||
|
||||
export type MapPoolSelectorProps = {
|
||||
mapPool: MapPool;
|
||||
handleRemoval?: () => void;
|
||||
handleMapPoolChange: (
|
||||
mapPool: MapPool,
|
||||
event?: Pick<CalendarEvent, "id" | "name">
|
||||
) => void;
|
||||
className?: string;
|
||||
recentEvents?: SerializedMapPoolEvent[];
|
||||
initialEvent?: Pick<CalendarEvent, "id" | "name">;
|
||||
};
|
||||
|
||||
export function MapPoolSelector({
|
||||
mapPool,
|
||||
handleMapPoolChange,
|
||||
handleRemoval,
|
||||
className,
|
||||
recentEvents,
|
||||
initialEvent,
|
||||
}: MapPoolSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [template, setTemplate] = React.useState<MapPoolTemplateValue>(
|
||||
initialEvent ? "event" : detectTemplate(mapPool)
|
||||
);
|
||||
|
||||
const initialSerializedEvent: SerializedMapPoolEvent | undefined = useOnce(
|
||||
() =>
|
||||
initialEvent && {
|
||||
...initialEvent,
|
||||
serializedMapPool: mapPool.serialized,
|
||||
}
|
||||
);
|
||||
|
||||
const handleStageModesChange = (newMapPool: MapPool) => {
|
||||
setTemplate(detectTemplate(newMapPool));
|
||||
handleMapPoolChange(newMapPool);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setTemplate("none");
|
||||
handleMapPoolChange(MapPool.EMPTY);
|
||||
};
|
||||
|
||||
const handleTemplateChange = (template: MapPoolTemplateValue) => {
|
||||
setTemplate(template);
|
||||
|
||||
if (template === "none" || template === "event") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startsWith(template, "preset:")) {
|
||||
const [, presetId] = split(template, ":");
|
||||
|
||||
handleMapPoolChange(MapPool[presetId]);
|
||||
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>();
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className={className}>
|
||||
<legend>{t("maps.mapPool")}</legend>
|
||||
<div className="stack horizontal sm justify-end">
|
||||
{handleRemoval && (
|
||||
<Button variant="minimal" onClick={handleRemoval}>
|
||||
{t("actions.remove")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
disabled={mapPool.isEmpty()}
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="stack md">
|
||||
<div className="maps__template-selection">
|
||||
<MapPoolTemplateSelect
|
||||
value={template}
|
||||
handleChange={handleTemplateChange}
|
||||
recentEvents={recentEvents}
|
||||
/>
|
||||
{template === "event" && (
|
||||
<TemplateEventSelection
|
||||
initialEvent={initialSerializedEvent}
|
||||
handleEventChange={handleMapPoolChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MapPoolStages
|
||||
mapPool={mapPool}
|
||||
handleMapPoolChange={handleStageModesChange}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export type MapPoolStagesProps = {
|
||||
mapPool: MapPool;
|
||||
handleMapPoolChange?: (newMapPool: MapPool) => void;
|
||||
};
|
||||
|
||||
export function MapPoolStages({
|
||||
mapPool,
|
||||
handleMapPoolChange,
|
||||
}: MapPoolStagesProps) {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
|
||||
const isPresentational = !handleMapPoolChange;
|
||||
|
||||
const stageRowIsVisible = (stageId: StageId) => {
|
||||
if (!isPresentational) return true;
|
||||
|
||||
return mapPool.hasStage(stageId);
|
||||
};
|
||||
|
||||
const handleModeChange = ({
|
||||
mode,
|
||||
stageId,
|
||||
}: {
|
||||
mode: ModeShort;
|
||||
stageId: StageId;
|
||||
}) => {
|
||||
const newMapPool = mapPool.parsed[mode].includes(stageId)
|
||||
? new MapPool({
|
||||
...mapPool.parsed,
|
||||
[mode]: mapPool.parsed[mode].filter((id) => id !== stageId),
|
||||
})
|
||||
: new MapPool({
|
||||
...mapPool.parsed,
|
||||
[mode]: [...mapPool.parsed[mode], stageId],
|
||||
});
|
||||
|
||||
handleMapPoolChange?.(newMapPool);
|
||||
};
|
||||
|
||||
const handleStageClear = (stageId: StageId) => {
|
||||
const newMapPool = new MapPool({
|
||||
TW: mapPool.parsed.TW.filter((id) => id !== stageId),
|
||||
SZ: mapPool.parsed.SZ.filter((id) => id !== stageId),
|
||||
TC: mapPool.parsed.TC.filter((id) => id !== stageId),
|
||||
RM: mapPool.parsed.RM.filter((id) => id !== stageId),
|
||||
CB: mapPool.parsed.CB.filter((id) => id !== stageId),
|
||||
});
|
||||
|
||||
handleMapPoolChange?.(newMapPool);
|
||||
};
|
||||
|
||||
const handleStageAdd = (stageId: StageId) => {
|
||||
const newMapPool = new MapPool({
|
||||
TW: [...mapPool.parsed.TW, stageId],
|
||||
SZ: [...mapPool.parsed.SZ, stageId],
|
||||
TC: [...mapPool.parsed.TC, stageId],
|
||||
RM: [...mapPool.parsed.RM, stageId],
|
||||
CB: [...mapPool.parsed.CB, stageId],
|
||||
});
|
||||
|
||||
handleMapPoolChange?.(newMapPool);
|
||||
};
|
||||
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{stageIds.filter(stageRowIsVisible).map((stageId) => (
|
||||
<div key={stageId} className="maps__stage-row">
|
||||
<Image
|
||||
className="maps__stage-image"
|
||||
alt=""
|
||||
path={stageImageUrl(stageId)}
|
||||
width={80}
|
||||
height={45}
|
||||
/>
|
||||
<div
|
||||
className="maps__stage-name-row"
|
||||
role="group"
|
||||
aria-labelledby={`${id}-stage-name-${stageId}`}
|
||||
>
|
||||
<div id={`${id}-stage-name-${stageId}`}>
|
||||
{t(`game-misc:STAGE_${stageId}`)}
|
||||
</div>
|
||||
<div className="maps__mode-buttons-container">
|
||||
{modes.map((mode) => {
|
||||
const selected = mapPool.parsed[mode.short].includes(stageId);
|
||||
|
||||
if (isPresentational && !selected) return null;
|
||||
if (isPresentational && selected) {
|
||||
return (
|
||||
<Image
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
})}
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={33}
|
||||
height={33}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode-button", "outline-theme", {
|
||||
selected,
|
||||
})}
|
||||
onClick={() =>
|
||||
handleModeChange?.({ mode: mode.short, stageId })
|
||||
}
|
||||
type="button"
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
aria-describedby={`${id}-stage-name-${stageId}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<Image
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
})}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!isPresentational &&
|
||||
(mapPool.hasStage(stageId) ? (
|
||||
<Button
|
||||
key="clear"
|
||||
onClick={() => handleStageClear(stageId)}
|
||||
icon={<CrossIcon />}
|
||||
variant="minimal"
|
||||
aria-label={t("common:actions.remove")}
|
||||
title={t("common:actions.remove")}
|
||||
tiny
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
key="select-all"
|
||||
onClick={() => handleStageAdd(stageId)}
|
||||
icon={<ArrowLongLeftIcon />}
|
||||
variant="minimal"
|
||||
aria-label={t("common:actions.selectAll")}
|
||||
title={t("common:actions.selectAll")}
|
||||
tiny
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MapModePresetId = "ANARCHY" | "ALL" | ModeShort;
|
||||
|
||||
const presetIds: MapModePresetId[] = ["ANARCHY", "ALL", ...modesShort];
|
||||
|
||||
type MapPoolTemplateValue =
|
||||
| "none"
|
||||
| `preset:${MapModePresetId}`
|
||||
| `recent-event:${string}`
|
||||
| "event";
|
||||
|
||||
function detectTemplate(mapPool: MapPool): MapPoolTemplateValue {
|
||||
for (const presetId of presetIds) {
|
||||
if (MapPool[presetId].serialized === mapPool.serialized) {
|
||||
return `preset:${presetId}`;
|
||||
}
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
type MapPoolTemplateSelectProps = {
|
||||
value: MapPoolTemplateValue;
|
||||
handleChange: (newValue: MapPoolTemplateValue) => void;
|
||||
recentEvents?: Pick<CalendarEvent, "id" | "name">[];
|
||||
};
|
||||
|
||||
function MapPoolTemplateSelect({
|
||||
handleChange,
|
||||
value,
|
||||
recentEvents,
|
||||
}: MapPoolTemplateSelectProps) {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
|
||||
return (
|
||||
<label className="stack sm">
|
||||
{t("common:maps.template")}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
handleChange(e.currentTarget.value as MapPoolTemplateValue);
|
||||
}}
|
||||
>
|
||||
<option value="none">{t("common:maps.template.none")}</option>
|
||||
<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}`}>
|
||||
{t(`common:maps.template.preset.${presetId}`)}
|
||||
</option>
|
||||
))}
|
||||
{modes.map((mode) => (
|
||||
<option key={mode.short} value={`preset:${mode.short}`}>
|
||||
{t(`common:maps.template.preset.onlyMode`, {
|
||||
modeName: t(`game-misc:MODE_LONG_${mode.short}`),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
{recentEvents && recentEvents.length > 0 && (
|
||||
<optgroup label={t("common:maps.template.yourRecentEvents")}>
|
||||
{recentEvents.map((event) => (
|
||||
<option key={event.id} value={`recent-event:${event.id}`}>
|
||||
{event.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
type TemplateEventSelectionProps = {
|
||||
handleEventChange: (
|
||||
mapPool: MapPool,
|
||||
event?: Pick<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>
|
||||
);
|
||||
}
|
||||
18
app/components/icons/ArrowLongLeft.tsx
Normal file
18
app/components/icons/ArrowLongLeft.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function ArrowLongLeftIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 15.75L3 12m0 0l3.75-3.75M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
app/db/models/calendar/findAllEventsWithMapPools.sql
Normal file
18
app/db/models/calendar/findAllEventsWithMapPools.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
select
|
||||
"CalendarEvent"."id",
|
||||
"CalendarEvent"."name",
|
||||
json_group_array(
|
||||
json_object(
|
||||
'stageId',
|
||||
"MapPoolMap"."stageId",
|
||||
'mode',
|
||||
"MapPoolMap"."mode"
|
||||
)
|
||||
) as "mapPool"
|
||||
from
|
||||
"CalendarEvent"
|
||||
join "MapPoolMap" on "CalendarEvent"."id" = "MapPoolMap"."calendarEventId"
|
||||
group by
|
||||
"CalendarEvent"."id"
|
||||
order by
|
||||
"CalendarEvent"."id" desc
|
||||
21
app/db/models/calendar/findRecentMapPoolsByAuthorId.sql
Normal file
21
app/db/models/calendar/findRecentMapPoolsByAuthorId.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
select
|
||||
"CalendarEvent"."id",
|
||||
"CalendarEvent"."name",
|
||||
json_group_array(
|
||||
json_object(
|
||||
'stageId',
|
||||
"MapPoolMap"."stageId",
|
||||
'mode',
|
||||
"MapPoolMap"."mode"
|
||||
)
|
||||
) as "mapPool"
|
||||
from
|
||||
"CalendarEvent"
|
||||
join "MapPoolMap" on "CalendarEvent"."id" = "MapPoolMap"."calendarEventId"
|
||||
where
|
||||
"CalendarEvent"."authorId" = @authorId
|
||||
group by
|
||||
"CalendarEvent"."id"
|
||||
order by
|
||||
"CalendarEvent"."id" desc
|
||||
limit 5
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
CalendarEventResultPlayer,
|
||||
MapPoolMap,
|
||||
} from "../../types";
|
||||
import { mapPoolListToMapPoolObject } from "~/modules/map-list-generator";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
|
||||
import createSql from "./create.sql";
|
||||
import updateSql from "./update.sql";
|
||||
@@ -36,6 +36,8 @@ import upcomingEventsSql from "./upcomingEvents.sql";
|
||||
import createMapPoolMapSql from "./createMapPoolMap.sql";
|
||||
import deleteMapPoolMapsSql from "./deleteMapPoolMaps.sql";
|
||||
import findMapPoolByEventIdSql from "./findMapPoolByEventId.sql";
|
||||
import findRecentMapPoolsByAuthorIdSql from "./findRecentMapPoolsByAuthorId.sql";
|
||||
import findAllEventsWithMapPoolsSql from "./findAllEventsWithMapPools.sql";
|
||||
|
||||
const createStm = sql.prepare(createSql);
|
||||
const updateStm = sql.prepare(updateSql);
|
||||
@@ -449,7 +451,7 @@ export function findMapPoolByEventId(calendarEventId: CalendarEvent["id"]) {
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
return mapPoolListToMapPoolObject(rows);
|
||||
return MapPool.parse(rows);
|
||||
}
|
||||
|
||||
const eventsToReportStm = sql.prepare(eventsToReportSql);
|
||||
@@ -467,3 +469,37 @@ export function eventsToReport(authorId?: CalendarEvent["authorId"]) {
|
||||
}) as Array<Pick<CalendarEvent, "id" | "name">>
|
||||
).map((row) => ({ id: row.id, name: row.name }));
|
||||
}
|
||||
|
||||
const findRecentMapPoolsByAuthorIdStm = sql.prepare(
|
||||
findRecentMapPoolsByAuthorIdSql
|
||||
);
|
||||
export function findRecentMapPoolsByAuthorId(
|
||||
authorId: CalendarEvent["authorId"]
|
||||
) {
|
||||
return (
|
||||
findRecentMapPoolsByAuthorIdStm.all({ authorId }) as Array<
|
||||
Pick<CalendarEvent, "id" | "name"> & {
|
||||
mapPool: string;
|
||||
}
|
||||
>
|
||||
).map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
serializedMapPool: MapPool.serialize(JSON.parse(row.mapPool)),
|
||||
}));
|
||||
}
|
||||
|
||||
const findAllEventsWithMapPoolsStm = sql.prepare(findAllEventsWithMapPoolsSql);
|
||||
export function findAllEventsWithMapPools() {
|
||||
return (
|
||||
findAllEventsWithMapPoolsStm.all() as Array<
|
||||
Pick<CalendarEvent, "id" | "name"> & {
|
||||
mapPool: string;
|
||||
}
|
||||
>
|
||||
).map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
serializedMapPool: MapPool.serialize(JSON.parse(row.mapPool)),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import type { EventsWithMapPoolsLoaderData } from "~/routes/calendar/map-pool-events";
|
||||
import type { UsersLoaderData } from "~/routes/users";
|
||||
|
||||
const ALL_USERS_ROUTE = "/users?_data=routes%2Fusers";
|
||||
import {
|
||||
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
|
||||
GET_ALL_USERS_ROUTE,
|
||||
} from "~/utils/urls";
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
@@ -10,7 +13,7 @@ const fetcher = async (url: string) => {
|
||||
|
||||
export function useUsers() {
|
||||
const { data, error } = useSWRImmutable<UsersLoaderData>(
|
||||
ALL_USERS_ROUTE,
|
||||
GET_ALL_USERS_ROUTE,
|
||||
fetcher
|
||||
);
|
||||
|
||||
@@ -20,3 +23,16 @@ export function useUsers() {
|
||||
isError: error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAllEventsWithMapPools() {
|
||||
const { data, error } = useSWRImmutable<EventsWithMapPoolsLoaderData>(
|
||||
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
|
||||
fetcher
|
||||
);
|
||||
|
||||
return {
|
||||
events: data?.events,
|
||||
isLoading: !error && !data,
|
||||
isError: error,
|
||||
};
|
||||
}
|
||||
|
||||
10
app/hooks/useOnce.ts
Normal file
10
app/hooks/useOnce.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Utility hook for calling `useMemo(f, [])`, when you're sure it needs no
|
||||
* revalidation but feel bad for getting shamed by eslint everytime :D
|
||||
*/
|
||||
export function useOnce<T>(factory: () => T) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return useMemo(factory, []);
|
||||
}
|
||||
@@ -1,24 +1,9 @@
|
||||
export const modes = [
|
||||
{
|
||||
short: "TW",
|
||||
long: "Turf War",
|
||||
},
|
||||
{
|
||||
short: "SZ",
|
||||
long: "Splat Zones",
|
||||
},
|
||||
{
|
||||
short: "TC",
|
||||
long: "Tower Control",
|
||||
},
|
||||
{
|
||||
short: "RM",
|
||||
long: "Rainmaker",
|
||||
},
|
||||
{
|
||||
short: "CB",
|
||||
long: "Clam Blitz",
|
||||
},
|
||||
{ short: "TW" },
|
||||
{ short: "SZ" },
|
||||
{ short: "TC" },
|
||||
{ short: "RM" },
|
||||
{ short: "CB" },
|
||||
] as const;
|
||||
|
||||
export const modesShort = modes.map((mode) => mode.short);
|
||||
|
||||
@@ -6,14 +6,13 @@ import type {
|
||||
ModeWithStage,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists";
|
||||
import type { MapPool } from "~/modules/map-pool-serializer";
|
||||
import clone from "just-clone";
|
||||
import type { MapPool, MapPoolObject } from "~/modules/map-pool-serializer";
|
||||
|
||||
const BACKLOG = 2;
|
||||
|
||||
export type Popularity = Map<ModeShort, Map<StageId, number>>;
|
||||
|
||||
type MapBucket = Map<number, MapPool>;
|
||||
type MapBucket = Map<number, MapPoolObject>;
|
||||
|
||||
/**
|
||||
* @param mapPool Map pool to work with as dictionary
|
||||
@@ -79,7 +78,7 @@ function addAndReturnMap(
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
} as MapPool);
|
||||
} as MapPoolObject);
|
||||
}
|
||||
|
||||
/* prettier-ignore */
|
||||
@@ -97,7 +96,7 @@ function getMapPopular(
|
||||
): StageId {
|
||||
const popularity_map_pool = new Map();
|
||||
for (const [stageId, votes] of popularity.get(mode)!.entries()) {
|
||||
if (mapPool[mode].includes(stageId)) {
|
||||
if (mapPool.parsed[mode].includes(stageId)) {
|
||||
popularity_map_pool.set(stageId, votes);
|
||||
}
|
||||
}
|
||||
@@ -150,7 +149,7 @@ function getMap(
|
||||
mapHistory: StageId[]
|
||||
) {
|
||||
if (!buckets.size) {
|
||||
buckets.set(0, clone(mapPool));
|
||||
buckets.set(0, mapPool.getClonedObject());
|
||||
}
|
||||
|
||||
for (let bucketNum = 0; bucketNum < buckets.size; bucketNum++) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { MapPoolMap } from "~/db/types";
|
||||
import type { ModeShort } from "../in-game-lists";
|
||||
import type { MapPool } from "../map-pool-serializer";
|
||||
import type { MapPool, MapPoolObject } from "../map-pool-serializer";
|
||||
|
||||
export function mapPoolToNonEmptyModes(mapPool: MapPool) {
|
||||
const result: ModeShort[] = [];
|
||||
|
||||
for (const [key, stages] of Object.entries(mapPool)) {
|
||||
for (const [key, stages] of Object.entries(mapPool.parsed)) {
|
||||
if (stages.length === 0) continue;
|
||||
|
||||
result.push(key as ModeShort);
|
||||
@@ -17,7 +17,7 @@ export function mapPoolToNonEmptyModes(mapPool: MapPool) {
|
||||
export function mapPoolListToMapPoolObject(
|
||||
mapPoolList: Array<Pick<MapPoolMap, "stageId" | "mode">>
|
||||
) {
|
||||
const result: MapPool = {
|
||||
const result: MapPoolObject = {
|
||||
TW: [],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
export {
|
||||
mapPoolToSerializedString,
|
||||
serializedStringToMapPool,
|
||||
} from "./serializer";
|
||||
|
||||
export type { MapPool } from "./types";
|
||||
export { MapPool } from "./map-pool";
|
||||
export type { MapPoolObject } from "./types";
|
||||
|
||||
136
app/modules/map-pool-serializer/map-pool.ts
Normal file
136
app/modules/map-pool-serializer/map-pool.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
mapPoolToSerializedString,
|
||||
serializedStringToMapPool,
|
||||
} from "./serializer";
|
||||
import type { ReadonlyMapPoolObject, MapPoolObject } from "./types";
|
||||
import clone from "just-clone";
|
||||
import type { MapPoolMap } from "~/db/types";
|
||||
import { mapPoolListToMapPoolObject } from "~/modules/map-list-generator";
|
||||
import {
|
||||
type ModeShort,
|
||||
type StageId,
|
||||
stageIds,
|
||||
} from "~/modules/in-game-lists";
|
||||
|
||||
type DbMapPoolList = Array<Pick<MapPoolMap, "stageId" | "mode">>;
|
||||
|
||||
export class MapPool {
|
||||
private source: string | ReadonlyMapPoolObject;
|
||||
private asSerialized?: string;
|
||||
private asObject?: ReadonlyMapPoolObject;
|
||||
|
||||
constructor(init: ReadonlyMapPoolObject | string | DbMapPoolList) {
|
||||
this.source = Array.isArray(init) ? mapPoolListToMapPoolObject(init) : init;
|
||||
}
|
||||
|
||||
static serialize(init: ReadonlyMapPoolObject | string | DbMapPoolList) {
|
||||
return new MapPool(init).serialized;
|
||||
}
|
||||
|
||||
static parse(init: MapPoolObject | string | DbMapPoolList) {
|
||||
return new MapPool(init).parsed;
|
||||
}
|
||||
|
||||
static toDbList(init: MapPoolObject | string | DbMapPoolList) {
|
||||
return new MapPool(init).dbList;
|
||||
}
|
||||
|
||||
get serialized(): string {
|
||||
if (this.asSerialized !== undefined) {
|
||||
return this.asSerialized;
|
||||
}
|
||||
|
||||
return (this.asSerialized =
|
||||
typeof this.source === "string"
|
||||
? this.source
|
||||
: mapPoolToSerializedString(this.source));
|
||||
}
|
||||
|
||||
get parsed(): ReadonlyMapPoolObject {
|
||||
if (this.asObject !== undefined) {
|
||||
return this.asObject;
|
||||
}
|
||||
|
||||
return (this.asObject =
|
||||
typeof this.source === "string"
|
||||
? serializedStringToMapPool(this.source)
|
||||
: this.source);
|
||||
}
|
||||
|
||||
get dbList(): DbMapPoolList {
|
||||
return Object.entries(this.parsed).flatMap(([mode, stages]) =>
|
||||
stages.flatMap((stageId) => ({ mode: mode as ModeShort, stageId }))
|
||||
);
|
||||
}
|
||||
|
||||
hasMode(mode: ModeShort): boolean {
|
||||
return this.parsed[mode].length > 0;
|
||||
}
|
||||
|
||||
hasStage(stageId: StageId): boolean {
|
||||
return Object.values(this.parsed).some((stages) =>
|
||||
stages.includes(stageId)
|
||||
);
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return Object.values(this.parsed).every((stages) => stages.length === 0);
|
||||
}
|
||||
|
||||
getClonedObject(): MapPoolObject {
|
||||
return clone(this.parsed) as MapPoolObject;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.serialized;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.parsed;
|
||||
}
|
||||
|
||||
static EMPTY = new MapPool({
|
||||
SZ: [],
|
||||
TC: [],
|
||||
CB: [],
|
||||
RM: [],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
static ALL = new MapPool({
|
||||
SZ: [...stageIds],
|
||||
TC: [...stageIds],
|
||||
CB: [...stageIds],
|
||||
RM: [...stageIds],
|
||||
TW: [...stageIds],
|
||||
});
|
||||
|
||||
static ANARCHY = new MapPool({
|
||||
SZ: [...stageIds],
|
||||
TC: [...stageIds],
|
||||
CB: [...stageIds],
|
||||
RM: [...stageIds],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
static SZ = new MapPool({
|
||||
...MapPool.EMPTY.parsed,
|
||||
SZ: [...stageIds],
|
||||
});
|
||||
static TC = new MapPool({
|
||||
...MapPool.EMPTY.parsed,
|
||||
TC: [...stageIds],
|
||||
});
|
||||
static CB = new MapPool({
|
||||
...MapPool.EMPTY.parsed,
|
||||
CB: [...stageIds],
|
||||
});
|
||||
static RM = new MapPool({
|
||||
...MapPool.EMPTY.parsed,
|
||||
RM: [...stageIds],
|
||||
});
|
||||
static TW = new MapPool({
|
||||
...MapPool.EMPTY.parsed,
|
||||
TW: [...stageIds],
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
mapPoolToSerializedString,
|
||||
serializedStringToMapPool,
|
||||
} from "./serializer";
|
||||
import type { MapPool } from "./types";
|
||||
import type { MapPoolObject } from "./types";
|
||||
|
||||
const Serializer = suite("Map pool serializer");
|
||||
|
||||
@@ -24,7 +24,7 @@ Serializer("Ignores invalid mode key", () => {
|
||||
});
|
||||
|
||||
Serializer("Matching serialization with IPLMapGen2", () => {
|
||||
const testMapPool: MapPool = {
|
||||
const testMapPool: MapPoolObject = {
|
||||
TW: [0, 3, 4, 7, 8],
|
||||
SZ: [0, 1, 3, 8, 10],
|
||||
TC: [1, 2, 5, 8, 9],
|
||||
@@ -36,7 +36,7 @@ Serializer("Matching serialization with IPLMapGen2", () => {
|
||||
});
|
||||
|
||||
Serializer("Omits key if mode has no maps", () => {
|
||||
const testPoolWithoutTw: MapPool = {
|
||||
const testPoolWithoutTw: MapPoolObject = {
|
||||
CB: [1, 2],
|
||||
RM: [1, 8],
|
||||
TC: [8, 4],
|
||||
@@ -50,7 +50,7 @@ Serializer("Omits key if mode has no maps", () => {
|
||||
});
|
||||
|
||||
Serializer("Returns empty string if no maps", () => {
|
||||
const testPoolWithoutTw: MapPool = {
|
||||
const testPoolWithoutTw: MapPoolObject = {
|
||||
CB: [],
|
||||
RM: [],
|
||||
TC: [],
|
||||
@@ -64,7 +64,7 @@ Serializer("Returns empty string if no maps", () => {
|
||||
});
|
||||
|
||||
Serializer("Value of two modes is the same with same maps", () => {
|
||||
const testPoolWithDuplicateMaps: MapPool = {
|
||||
const testPoolWithDuplicateMaps: MapPoolObject = {
|
||||
CB: [1, 2],
|
||||
RM: [1, 2],
|
||||
TC: [],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import { modesShort, type StageId, stageIds } from "../in-game-lists";
|
||||
import type { MapPool } from "./types";
|
||||
import type { MapPoolObject, ReadonlyMapPoolObject } from "./types";
|
||||
|
||||
export function mapPoolToSerializedString(mapPool: MapPool): string {
|
||||
export function mapPoolToSerializedString(
|
||||
mapPool: ReadonlyMapPoolObject
|
||||
): string {
|
||||
const serializedModes = [];
|
||||
|
||||
for (const mode of modesShort) {
|
||||
@@ -15,7 +17,7 @@ export function mapPoolToSerializedString(mapPool: MapPool): string {
|
||||
return serializedModes.join(";").toLowerCase();
|
||||
}
|
||||
|
||||
function stageIdsToBinary(input: StageId[]) {
|
||||
function stageIdsToBinary(input: readonly StageId[]) {
|
||||
let result = "1";
|
||||
|
||||
for (const stageId of stageIds) {
|
||||
@@ -33,8 +35,10 @@ function binaryToHex(binary: string) {
|
||||
return parseInt(binary, 2).toString(16);
|
||||
}
|
||||
|
||||
export function serializedStringToMapPool(serialized: string) {
|
||||
const result: MapPool = {
|
||||
export function serializedStringToMapPool(
|
||||
serialized: string
|
||||
): ReadonlyMapPoolObject {
|
||||
const result: MapPoolObject = {
|
||||
SZ: [],
|
||||
CB: [],
|
||||
RM: [],
|
||||
@@ -58,7 +62,7 @@ export function serializedStringToMapPool(serialized: string) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function binaryToStageIds(binary: string): StageId[] {
|
||||
function binaryToStageIds(binary: string): readonly StageId[] {
|
||||
const result: StageId[] = [];
|
||||
|
||||
// first 1 is padding
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ModeShort } from "../in-game-lists";
|
||||
import type { StageId } from "../in-game-lists";
|
||||
|
||||
export type MapPool = Record<ModeShort, StageId[]>;
|
||||
export type MapPoolObject = Record<ModeShort, StageId[]>;
|
||||
export type ReadonlyMapPoolObject = Readonly<
|
||||
Record<ModeShort, readonly StageId[]>
|
||||
>;
|
||||
|
||||
@@ -33,14 +33,15 @@ import { discordFullName, makeTitle } from "~/utils/strings";
|
||||
import {
|
||||
calendarEditPage,
|
||||
calendarReportWinnersPage,
|
||||
mapsPage,
|
||||
navIconUrl,
|
||||
readonlyMapsPage,
|
||||
resolveBaseUrl,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { actualNumber, id } from "~/utils/zod";
|
||||
import { MapPoolSelector } from "../components/MapPoolSelector";
|
||||
import { MapPoolStages } from "~/components/MapPoolSelector";
|
||||
import { Tags } from "../components/Tags";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [
|
||||
@@ -244,10 +245,10 @@ function MapPoolInfo() {
|
||||
return (
|
||||
<Section title="Map pool">
|
||||
<div className="event__map-pool-section">
|
||||
<MapPoolSelector mapPool={data.mapPool} />
|
||||
<MapPoolStages mapPool={new MapPool(data.mapPool)} />
|
||||
<LinkButton
|
||||
className="event__create-map-list-link"
|
||||
to={mapsPage(data.event.eventId)}
|
||||
to={readonlyMapsPage(data.event.eventId)}
|
||||
variant="outlined"
|
||||
tiny
|
||||
>
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image } from "~/components/Image";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import { modes, stageIds } from "~/modules/in-game-lists";
|
||||
import { type MapPool } from "~/modules/map-pool-serializer";
|
||||
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
|
||||
|
||||
export function MapPoolSelector({
|
||||
mapPool,
|
||||
handleMapPoolChange,
|
||||
}: {
|
||||
mapPool: MapPool;
|
||||
handleMapPoolChange?: ({
|
||||
mode,
|
||||
stageId,
|
||||
}: {
|
||||
mode: ModeShort;
|
||||
stageId: StageId;
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc", "calendar"]);
|
||||
|
||||
const isPresentational = !handleMapPoolChange;
|
||||
|
||||
const stageRowIsVisible = (stageId: StageId) => {
|
||||
if (!isPresentational) return true;
|
||||
|
||||
return modes.some((mode) => mapPool[mode.short].includes(stageId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{stageIds.filter(stageRowIsVisible).map((stageId) => (
|
||||
<div key={stageId} className="maps__stage-row">
|
||||
<Image
|
||||
className="maps__stage-image"
|
||||
alt=""
|
||||
path={stageImageUrl(stageId)}
|
||||
width={80}
|
||||
height={45}
|
||||
/>
|
||||
<div className="maps__stage-name-row">
|
||||
<div>{t(`game-misc:STAGE_${stageId}`)}</div>
|
||||
<div className="maps__mode-buttons-container">
|
||||
{modes.map((mode) => {
|
||||
const selected = (mapPool[mode.short] as StageId[]).includes(
|
||||
stageId
|
||||
);
|
||||
|
||||
if (isPresentational && !selected) return null;
|
||||
if (isPresentational && selected) {
|
||||
return (
|
||||
<Image
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
})}
|
||||
alt={mode.long}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={33}
|
||||
height={33}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode-button", "outline-theme", {
|
||||
selected,
|
||||
})}
|
||||
onClick={() =>
|
||||
handleMapPoolChange?.({ mode: mode.short, stageId })
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Image
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
})}
|
||||
alt={mode.long}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/routes/calendar/map-pool-events.ts
Normal file
12
app/routes/calendar/map-pool-events.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { json, type SerializeFrom } from "@remix-run/node";
|
||||
import { findAllEventsWithMapPools } from "~/db/models/calendar/queries.server";
|
||||
|
||||
export const loader = () => {
|
||||
return json({
|
||||
events: findAllEventsWithMapPools(),
|
||||
});
|
||||
};
|
||||
|
||||
export type EventsWithMapPoolsLoaderData = SerializeFrom<typeof loader>;
|
||||
export type SerializedMapPoolEvent =
|
||||
EventsWithMapPoolsLoaderData["events"][number];
|
||||
@@ -20,19 +20,13 @@ import { TrashIcon } from "~/components/icons/Trash";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { CALENDAR_EVENT } from "~/constants";
|
||||
import { db } from "~/db";
|
||||
import type { Badge as BadgeType, CalendarEventTag } from "~/db/types";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { requireUser } from "~/modules/auth";
|
||||
import { i18next } from "~/modules/i18n";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import {
|
||||
mapPoolToSerializedString,
|
||||
serializedStringToMapPool,
|
||||
type MapPool,
|
||||
} from "~/modules/map-pool-serializer";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
import { canEditCalendarEvent } from "~/permissions";
|
||||
import calendarNewStyles from "~/styles/calendar-new.css";
|
||||
import mapsStyles from "~/styles/maps.css";
|
||||
@@ -58,7 +52,7 @@ import {
|
||||
safeJSONParse,
|
||||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { MapPoolSelector } from "./components/MapPoolSelector";
|
||||
import { MapPoolSelector } from "~/components/MapPoolSelector";
|
||||
import { Tags } from "./components/Tags";
|
||||
|
||||
const MIN_DATE = new Date(Date.UTC(2015, 4, 28));
|
||||
@@ -152,10 +146,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
const deserializedMaps = (() => {
|
||||
if (!data.pool) return;
|
||||
|
||||
const mapPool = serializedStringToMapPool(data.pool);
|
||||
return Object.entries(mapPool).flatMap(([mode, stages]) =>
|
||||
stages.flatMap((stageId) => ({ mode: mode as ModeShort, stageId }))
|
||||
);
|
||||
return MapPool.toDbList(data.pool);
|
||||
})();
|
||||
|
||||
if (data.eventToEditId) {
|
||||
@@ -201,6 +192,9 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
|
||||
return json({
|
||||
managedBadges: db.badges.managedByUserId(user.id),
|
||||
recentEventsWithMapPools: db.calendarEvents.findRecentMapPoolsByAuthorId(
|
||||
user.id
|
||||
),
|
||||
eventToEdit: canEditEvent
|
||||
? {
|
||||
...eventToEdit,
|
||||
@@ -526,63 +520,43 @@ function BadgesAdder() {
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_MAP_POOL = {
|
||||
SZ: [],
|
||||
TC: [],
|
||||
CB: [],
|
||||
RM: [],
|
||||
TW: [],
|
||||
};
|
||||
function MapPoolSection() {
|
||||
const { t } = useTranslation(["game-misc", "calendar"]);
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { eventToEdit, recentEventsWithMapPools } =
|
||||
useLoaderData<typeof loader>();
|
||||
const [mapPool, setMapPool] = React.useState<MapPool>(
|
||||
data.eventToEdit?.mapPool ?? DEFAULT_MAP_POOL
|
||||
eventToEdit?.mapPool ? new MapPool(eventToEdit.mapPool) : MapPool.EMPTY
|
||||
);
|
||||
const [includeMapPool, setIncludeMapPool] = React.useState(
|
||||
Boolean(data.eventToEdit?.mapPool)
|
||||
Boolean(eventToEdit?.mapPool)
|
||||
);
|
||||
|
||||
const handleMapPoolChange = ({
|
||||
mode,
|
||||
stageId,
|
||||
}: {
|
||||
mode: ModeShort;
|
||||
stageId: StageId;
|
||||
}) => {
|
||||
const newMapPool = mapPool[mode].includes(stageId)
|
||||
? {
|
||||
...mapPool,
|
||||
[mode]: mapPool[mode].filter((id) => id !== stageId),
|
||||
}
|
||||
: {
|
||||
...mapPool,
|
||||
[mode]: [...mapPool[mode], stageId],
|
||||
};
|
||||
const id = React.useId();
|
||||
|
||||
setMapPool(newMapPool);
|
||||
};
|
||||
return includeMapPool ? (
|
||||
<>
|
||||
<input type="hidden" name="pool" value={mapPool.serialized} />
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{includeMapPool && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="pool"
|
||||
value={mapPoolToSerializedString(mapPool)}
|
||||
/>
|
||||
)}
|
||||
<Label>{t("calendar:forms.mapPool")}</Label>
|
||||
<div className="stack md">
|
||||
<Toggle checked={includeMapPool} setChecked={setIncludeMapPool} tiny />
|
||||
{includeMapPool && (
|
||||
<MapPoolSelector
|
||||
mapPool={mapPool}
|
||||
handleMapPoolChange={handleMapPoolChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MapPoolSelector
|
||||
className="w-full"
|
||||
mapPool={mapPool}
|
||||
handleRemoval={() => setIncludeMapPool(false)}
|
||||
handleMapPoolChange={setMapPool}
|
||||
recentEvents={recentEventsWithMapPools}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor={id}>{t("common:maps.mapPool")}</label>
|
||||
<Button
|
||||
id={id}
|
||||
variant="outlined"
|
||||
tiny
|
||||
onClick={() => setIncludeMapPool(true)}
|
||||
>
|
||||
{t("common:actions.add")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,48 +7,41 @@ import type {
|
||||
import type { ShouldReloadFunction } from "@remix-run/react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Image } from "~/components/Image";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { db } from "~/db";
|
||||
import { i18next } from "~/modules/i18n";
|
||||
import {
|
||||
modes,
|
||||
stageIds,
|
||||
type ModeShort,
|
||||
type ModeWithStage,
|
||||
type StageId,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { stageIds, type ModeWithStage } from "~/modules/in-game-lists";
|
||||
import {
|
||||
generateMapList,
|
||||
mapPoolToNonEmptyModes,
|
||||
modesOrder,
|
||||
} from "~/modules/map-list-generator";
|
||||
import {
|
||||
mapPoolToSerializedString,
|
||||
serializedStringToMapPool,
|
||||
} from "~/modules/map-pool-serializer";
|
||||
import type { MapPool } from "~/modules/map-pool-serializer/types";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
import styles from "~/styles/maps.css";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import {
|
||||
calendarEventPage,
|
||||
ipLabsMaps,
|
||||
modeImageUrl,
|
||||
stageImageUrl,
|
||||
} from "~/utils/urls";
|
||||
import { calendarEventPage, ipLabsMaps } from "~/utils/urls";
|
||||
import { type SendouRouteHandle } from "~/utils/remix";
|
||||
import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector";
|
||||
import { EditIcon } from "~/components/icons/Edit";
|
||||
import { useOnce } from "~/hooks/useOnce";
|
||||
import { getUser } from "~/modules/auth";
|
||||
import type { CalendarEvent } from "~/db/types";
|
||||
|
||||
const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2;
|
||||
|
||||
export const unstable_shouldReload: ShouldReloadFunction = () => false;
|
||||
export const unstable_shouldReload: ShouldReloadFunction = ({ url }) => {
|
||||
const searchParams = new URL(url).searchParams;
|
||||
// Only let loader reload data if we're not currently editing the map pool
|
||||
// and persisting it in the search params.
|
||||
return searchParams.has("readonly");
|
||||
};
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
@@ -70,6 +63,7 @@ export const handle: SendouRouteHandle = {
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
const user = await getUser(request);
|
||||
const url = new URL(request.url);
|
||||
const calendarEventId = url.searchParams.get("eventId");
|
||||
const t = await i18next.getFixedT(request);
|
||||
@@ -88,40 +82,54 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
mapPool: event
|
||||
? db.calendarEvents.findMapPoolByEventId(event.eventId)
|
||||
: null,
|
||||
recentEventsWithMapPools: user
|
||||
? db.calendarEvents.findRecentMapPoolsByAuthorId(user.id)
|
||||
: undefined,
|
||||
title: makeTitle([t("pages.maps")]),
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_MAP_POOL = {
|
||||
SZ: [...stageIds],
|
||||
TC: [...stageIds],
|
||||
CB: [...stageIds],
|
||||
RM: [...stageIds],
|
||||
TW: [],
|
||||
};
|
||||
|
||||
export default function MapListPage() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { mapPool, handleMapPoolChange } = useSearchParamMapPool();
|
||||
const { mapPool, handleMapPoolChange, readonly, switchToEditMode } =
|
||||
useSearchParamPersistedMapPool();
|
||||
|
||||
return (
|
||||
<Main className="maps__container stack lg">
|
||||
{data.calendarEvent && !searchParams.has("pool") && (
|
||||
<div className="maps__pool-info">
|
||||
{t("common:maps.mapPool")}:{" "}
|
||||
<Link to={calendarEventPage(data.calendarEvent.id)}>
|
||||
{data.calendarEvent.name}
|
||||
</Link>
|
||||
{searchParams.has("readonly") && data.calendarEvent && (
|
||||
<div className="maps__pool-meta">
|
||||
<div className="maps__pool-info">
|
||||
{t("common:maps.mapPool")}:{" "}
|
||||
{
|
||||
<Link to={calendarEventPage(data.calendarEvent.id)}>
|
||||
{data.calendarEvent.name}
|
||||
</Link>
|
||||
}
|
||||
</div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={switchToEditMode}
|
||||
tiny
|
||||
icon={<EditIcon />}
|
||||
>
|
||||
{t("common:actions.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MapPoolSelector
|
||||
mapPool={mapPool}
|
||||
handleMapPoolChange={handleMapPoolChange}
|
||||
/>
|
||||
{readonly ? (
|
||||
<MapPoolStages mapPool={mapPool} />
|
||||
) : (
|
||||
<MapPoolSelector
|
||||
mapPool={mapPool}
|
||||
handleMapPoolChange={handleMapPoolChange}
|
||||
recentEvents={data.recentEventsWithMapPools}
|
||||
initialEvent={data.calendarEvent}
|
||||
/>
|
||||
)}
|
||||
<a
|
||||
href={ipLabsMaps(mapPoolToSerializedString(mapPool))}
|
||||
href={ipLabsMaps(mapPool.serialized)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="maps__tournament-map-list-link"
|
||||
@@ -133,110 +141,56 @@ export default function MapListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function useSearchParamMapPool() {
|
||||
function useSearchParamPersistedMapPool() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const mapPool = (() => {
|
||||
const initialMapPool = useOnce(() => {
|
||||
if (searchParams.has("pool")) {
|
||||
return serializedStringToMapPool(searchParams.get("pool")!);
|
||||
return new MapPool(searchParams.get("pool")!);
|
||||
}
|
||||
|
||||
if (data?.mapPool) {
|
||||
return data.mapPool;
|
||||
if (data.mapPool) {
|
||||
return new MapPool(data.mapPool);
|
||||
}
|
||||
|
||||
return DEFAULT_MAP_POOL;
|
||||
})();
|
||||
return MapPool.ANARCHY;
|
||||
});
|
||||
|
||||
const handleMapPoolChange = ({
|
||||
mode,
|
||||
stageId,
|
||||
}: {
|
||||
mode: ModeShort;
|
||||
stageId: StageId;
|
||||
}) => {
|
||||
const newMapPool = mapPool[mode].includes(stageId)
|
||||
? {
|
||||
...mapPool,
|
||||
[mode]: mapPool[mode].filter((id) => id !== stageId),
|
||||
}
|
||||
: {
|
||||
...mapPool,
|
||||
[mode]: [...mapPool[mode], stageId],
|
||||
};
|
||||
const [mapPool, setMapPool] = React.useState(initialMapPool);
|
||||
|
||||
const handleMapPoolChange = (
|
||||
newMapPool: MapPool,
|
||||
event?: Pick<CalendarEvent, "id" | "name">
|
||||
) => {
|
||||
setMapPool(newMapPool);
|
||||
setSearchParams(
|
||||
{
|
||||
pool: mapPoolToSerializedString(newMapPool),
|
||||
},
|
||||
event
|
||||
? { eventId: event.id.toString() }
|
||||
: {
|
||||
pool: newMapPool.serialized,
|
||||
},
|
||||
{ replace: true, state: { scroll: false } }
|
||||
);
|
||||
};
|
||||
|
||||
const switchToEditMode = () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.delete("readonly");
|
||||
setSearchParams(newSearchParams, {
|
||||
replace: false,
|
||||
state: { scroll: false },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
mapPool,
|
||||
readonly: searchParams.has("readonly"),
|
||||
handleMapPoolChange,
|
||||
switchToEditMode,
|
||||
};
|
||||
}
|
||||
|
||||
function MapPoolSelector({
|
||||
mapPool,
|
||||
handleMapPoolChange,
|
||||
}: {
|
||||
mapPool: MapPool;
|
||||
handleMapPoolChange: (args: { mode: ModeShort; stageId: StageId }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{stageIds.map((stageId) => (
|
||||
<div key={stageId} className="maps__stage-row">
|
||||
<Image
|
||||
className="maps__stage-image"
|
||||
alt=""
|
||||
path={stageImageUrl(stageId)}
|
||||
width={80}
|
||||
height={45}
|
||||
/>
|
||||
<div className="maps__stage-name-row">
|
||||
<div>{t(`game-misc:STAGE_${stageId}`)}</div>
|
||||
<div className="maps__mode-buttons-container">
|
||||
{modes.map((mode) => {
|
||||
const selected = mapPool[mode.short].includes(stageId);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode-button", "outline-theme", {
|
||||
selected,
|
||||
})}
|
||||
onClick={() =>
|
||||
handleMapPoolChange({ mode: mode.short, stageId })
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Image
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
})}
|
||||
alt={mode.long}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapListCreator({ mapPool }: { mapPool: MapPool }) {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
const [mapList, setMapList] = React.useState<ModeWithStage[]>();
|
||||
@@ -258,13 +212,16 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) {
|
||||
setMapList(list);
|
||||
};
|
||||
|
||||
const disabled =
|
||||
mapPool.isEmpty() || (szEveryOther && !mapPool.hasMode("SZ"));
|
||||
|
||||
return (
|
||||
<div className="maps__map-list-creator">
|
||||
<div className="maps__toggle-container">
|
||||
<Label>{t("common:maps.halfSz")}</Label>
|
||||
<Toggle checked={szEveryOther} setChecked={setSzEveryOther} tiny />
|
||||
</div>
|
||||
<Button onClick={handleCreateMaplist}>
|
||||
<Button onClick={handleCreateMaplist} disabled={disabled}>
|
||||
{t("common:maps.createMapList")}
|
||||
</Button>
|
||||
{mapList && (
|
||||
@@ -272,7 +229,12 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) {
|
||||
<ol className="maps__map-list">
|
||||
{mapList.map(({ mode, stageId }, i) => (
|
||||
<li key={i}>
|
||||
{t(`game-misc:MODE_SHORT_${mode}`)}{" "}
|
||||
<abbr
|
||||
className="maps__mode-abbr"
|
||||
title={t(`game-misc:MODE_LONG_${mode}`)}
|
||||
>
|
||||
{t(`game-misc:MODE_SHORT_${mode}`)}
|
||||
</abbr>{" "}
|
||||
{t(`game-misc:STAGE_${stageId}`)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.calendar-new__container {
|
||||
max-width: 32rem;
|
||||
max-width: 38rem;
|
||||
}
|
||||
|
||||
.calendar-new__select {
|
||||
|
||||
@@ -243,7 +243,8 @@ article {
|
||||
|
||||
select {
|
||||
all: unset;
|
||||
width: 90%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--rounded);
|
||||
background: var(--select-background, var(--bg-lighter));
|
||||
@@ -492,10 +493,18 @@ dialog::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
.combobox-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.combobox-input {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.combobox-input.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.combobox-options {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
@@ -509,6 +518,10 @@ dialog::backdrop {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.combobox-options.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.combobox-options.empty {
|
||||
padding-block: var(--s-1-5);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
.maps__container {
|
||||
max-width: 32rem;
|
||||
max-width: 38rem;
|
||||
}
|
||||
|
||||
.maps__pool-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.maps__pool-info {
|
||||
font-size: var(--fonts-xxs);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.maps__pool-info a {
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
@@ -49,29 +59,27 @@
|
||||
|
||||
.maps__mode-button {
|
||||
padding: 0;
|
||||
padding: var(--s-1-5);
|
||||
border: none;
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-darker);
|
||||
padding: var(--s-1);
|
||||
border: 2px solid var(--bg-darker);
|
||||
border-radius: var(--rounded-full);
|
||||
background-color: transparent;
|
||||
color: var(--theme);
|
||||
opacity: 1 !important;
|
||||
outline: initial;
|
||||
}
|
||||
|
||||
.maps__mode-button.selected {
|
||||
background-color: var(--theme-very-transparent);
|
||||
border: 2px solid transparent;
|
||||
background-color: var(--bg-mode-active);
|
||||
}
|
||||
|
||||
.maps__stage-image {
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.maps__mode {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.maps__mode.selected {
|
||||
filter: unset;
|
||||
.maps__mode:not(.selected) {
|
||||
filter: var(--inactive-image-filter);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.maps__map-list-creator {
|
||||
@@ -96,3 +104,21 @@
|
||||
font-weight: var(--semi-bold);
|
||||
margin-block-start: var(--s-4);
|
||||
}
|
||||
|
||||
.maps__mode-abbr {
|
||||
color: var(--text-lighter);
|
||||
font-weight: var(--bold);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maps__template-selection {
|
||||
display: grid;
|
||||
gap: var(--s-2);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.maps__template-selection {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,10 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ html {
|
||||
--bg-darker-transparent: hsla(202deg 90% 90% / 65%);
|
||||
--bg-ability: rgb(3 6 7);
|
||||
--bg-badge: #000;
|
||||
--bg-mode-active: hsl(255deg 66.7% 50% / 40%);
|
||||
--abilities-button-bg: hsl(237deg 32% 30%);
|
||||
--badge-text: rgb(255 255 255 / 95%);
|
||||
--border: hsl(237deg 100% 86%);
|
||||
@@ -31,6 +32,7 @@ html {
|
||||
--theme-semi-transparent-vibrant: hsl(255deg 100% 81% / 75%);
|
||||
--theme-secondary: hsl(85deg 66.7% 55.3%);
|
||||
--rounded: 16px;
|
||||
--rounded-full: 200px;
|
||||
--rounded-sm: 10px;
|
||||
--fonts-xl: 1.5rem;
|
||||
--fonts-lg: 1.2rem;
|
||||
@@ -73,6 +75,7 @@ html {
|
||||
--s-96: 2rem;
|
||||
--sparse: 0.4px;
|
||||
--label-margin: var(--s-1);
|
||||
--inactive-image-filter: grayscale(100%) brightness(30%);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
@@ -84,6 +87,7 @@ html.dark {
|
||||
--bg-darker-transparent: hsla(237.3deg 42.3% 26.6% / 90%);
|
||||
--bg-ability: rgb(17 19 43);
|
||||
--bg-badge: #000;
|
||||
--bg-mode-active: var(--theme-transparent);
|
||||
--abilities-button-bg: hsl(237.3deg 42.3% 26.6%);
|
||||
--border: hsl(237.3deg 42.3% 45.6%);
|
||||
--button-text: rgb(0 0 0 / 85%);
|
||||
@@ -104,6 +108,7 @@ html.dark {
|
||||
--theme-transparent-vibrant: hsl(255deg 78% 65% / 54%);
|
||||
--theme-semi-transparent-vibrant: hsl(255deg 78% 65% / 75%);
|
||||
--theme-secondary: hsl(85deg 66.7% 55.3%);
|
||||
--inactive-image-filter: grayscale(100%) brightness(130%);
|
||||
}
|
||||
|
||||
html.dark .light-mode-only {
|
||||
|
||||
@@ -30,3 +30,30 @@ export function semiRandomId() {
|
||||
|
||||
export const rawSensToString = (sens: number) =>
|
||||
`${sens > 0 ? "+" : ""}${sens / 10}`;
|
||||
|
||||
type WithStart<
|
||||
S extends string,
|
||||
Start extends string
|
||||
> = S extends `${Start}${infer Rest}` ? `${Start}${Rest}` : never;
|
||||
|
||||
export function startsWith<S extends string, Start extends string>(
|
||||
str: S,
|
||||
start: Start
|
||||
): str is WithStart<S, Start> {
|
||||
return str.startsWith(start);
|
||||
}
|
||||
|
||||
type Split<S extends string, Sep extends string> = string extends S
|
||||
? string[]
|
||||
: S extends ""
|
||||
? []
|
||||
: S extends `${infer T}${Sep}${infer U}`
|
||||
? [T, ...Split<U, Sep>]
|
||||
: [S];
|
||||
|
||||
export function split<S extends string, Sep extends string>(
|
||||
str: S,
|
||||
seperator: Sep
|
||||
) {
|
||||
return str.split(seperator) as Split<S, Sep>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import slugify from "slugify";
|
||||
import type { Badge, GearType, MapPoolMap, User } from "~/db/types";
|
||||
import type {
|
||||
Badge,
|
||||
CalendarEvent,
|
||||
GearType,
|
||||
MapPoolMap,
|
||||
User,
|
||||
} from "~/db/types";
|
||||
import type { ModeShort, weaponCategories } from "~/modules/in-game-lists";
|
||||
import type {
|
||||
Ability,
|
||||
@@ -42,6 +48,9 @@ export const COMMON_PREVIEW_IMAGE = "/img/layout/common-preview.png";
|
||||
export const ERROR_GIRL_IMAGE_PATH = `/img/layout/error-girl`;
|
||||
export const LOGO_PATH = `/img/layout/logo`;
|
||||
|
||||
export const GET_ALL_USERS_ROUTE = "/users";
|
||||
export const GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE = "/calendar/map-pool-events";
|
||||
|
||||
interface UserLinkArgs {
|
||||
discordId: User["discordId"];
|
||||
customUrl?: User["customUrl"];
|
||||
@@ -74,6 +83,8 @@ export const calendarReportWinnersPage = (eventId: number) =>
|
||||
`/calendar/${eventId}/report-winners`;
|
||||
export const mapsPage = (eventId?: MapPoolMap["calendarEventId"]) =>
|
||||
`/maps${eventId ? `?eventId=${eventId}` : ""}`;
|
||||
export const readonlyMapsPage = (eventId: CalendarEvent["id"]) =>
|
||||
`/maps?readonly&eventId=${eventId}`;
|
||||
export const articlePage = (slug: string) => `/a/${slug}`;
|
||||
export const analyzerPage = (args?: {
|
||||
weaponId: MainWeaponId;
|
||||
|
||||
@@ -34,17 +34,32 @@
|
||||
"actions.delete": "Löschen",
|
||||
"actions.loadMore": "Mehr laden",
|
||||
"actions.close": "Schließen",
|
||||
"actions.loading": "Lädt...",
|
||||
"actions.clear": "Leeren",
|
||||
"actions.selectAll": "Alle auswählen",
|
||||
"actions.search": "Suchen",
|
||||
|
||||
"maps.createMapList": "Arenen-Liste erstellen",
|
||||
"maps.halfSz": "50% Herrschaft",
|
||||
"maps.mapPool": "Arenen-Pool",
|
||||
"maps.tournamentMaplist": "Arenen-Liste für Turnier erstellen (maps.iplabs.ink)",
|
||||
"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",
|
||||
"maps.template.preset.ALL": "Alle Modi",
|
||||
"maps.template.preset.onlyMode": "Nur {{modeName}}",
|
||||
|
||||
"results": "Ergebnisse",
|
||||
|
||||
"forms.name": "Name",
|
||||
"forms.description": "Beschreibung",
|
||||
"forms.errors.title": "Diese Fehler müssen behoben werden",
|
||||
"forms.errors.noSearchMatches": "Keine Suchergebnisse",
|
||||
|
||||
"errors.genericReload": "Etwas ist schiefgegangen. Versuche die Seite neu zu laden.",
|
||||
|
||||
"tag.name.BADGE": "Abzeichen-Preise",
|
||||
"tag.name.SPECIAL": "Spezielle Regeln",
|
||||
|
||||
@@ -15,5 +15,10 @@
|
||||
"MODE_SHORT_SZ": "HS",
|
||||
"MODE_SHORT_TC": "TK",
|
||||
"MODE_SHORT_RM": "OG",
|
||||
"MODE_SHORT_CB": "MC"
|
||||
"MODE_SHORT_CB": "MC",
|
||||
"MODE_LONG_TW": "Revierkampf",
|
||||
"MODE_LONG_SZ": "Herrschaft",
|
||||
"MODE_LONG_TC": "Turmkommando",
|
||||
"MODE_LONG_RM": "Operation Goldfisch",
|
||||
"MODE_LONG_CB": "Muschelchaos"
|
||||
}
|
||||
|
||||
@@ -36,17 +36,32 @@
|
||||
"actions.loadMore": "Load more",
|
||||
"actions.copyToClipboard": "Copy to clipboard",
|
||||
"actions.close": "Close",
|
||||
"actions.loading": "Loading...",
|
||||
"actions.clear": "Clear",
|
||||
"actions.selectAll": "Select All",
|
||||
"actions.search": "Search",
|
||||
|
||||
"maps.createMapList": "Create map list",
|
||||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "Map pool",
|
||||
"maps.tournamentMaplist": "Create tournament map list (maps.iplabs.ink)",
|
||||
"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",
|
||||
"maps.template.preset.ALL": "All Modes",
|
||||
"maps.template.preset.onlyMode": "Only {{modeName}}",
|
||||
|
||||
"results": "Results",
|
||||
|
||||
"forms.name": "Name",
|
||||
"forms.description": "Description",
|
||||
"forms.errors.title": "Following errors need to be fixed",
|
||||
"forms.errors.noSearchMatches": "No matches found",
|
||||
|
||||
"errors.genericReload": "Something went wrong. Try reloading the page.",
|
||||
|
||||
"tag.name.BADGE": "Badge prizes",
|
||||
"tag.name.SPECIAL": "Special rules",
|
||||
|
||||
@@ -15,5 +15,10 @@
|
||||
"MODE_SHORT_SZ": "SZ",
|
||||
"MODE_SHORT_TC": "TC",
|
||||
"MODE_SHORT_RM": "RM",
|
||||
"MODE_SHORT_CB": "CB"
|
||||
"MODE_SHORT_CB": "CB",
|
||||
"MODE_LONG_TW": "Turf War",
|
||||
"MODE_LONG_SZ": "Splat Zones",
|
||||
"MODE_LONG_TC": "Tower Control",
|
||||
"MODE_LONG_RM": "Rainmaker",
|
||||
"MODE_LONG_CB": "Clam Blitz"
|
||||
}
|
||||
|
||||
@@ -39,12 +39,26 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**60/61**
|
||||
**60/75**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- pages.object-damage-calculator
|
||||
- actions.loading
|
||||
- actions.clear
|
||||
- actions.selectAll
|
||||
- actions.search
|
||||
- maps.template
|
||||
- maps.template.none
|
||||
- maps.template.event
|
||||
- maps.template.presets
|
||||
- maps.template.yourRecentEvents
|
||||
- maps.template.preset.ANARCHY
|
||||
- maps.template.preset.ALL
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
|
||||
</details>
|
||||
|
||||
@@ -67,9 +81,20 @@
|
||||
|
||||
</details>
|
||||
|
||||
### 🟢 game-misc.json
|
||||
### 🟡 game-misc.json
|
||||
|
||||
**17/17**
|
||||
**17/22**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- MODE_LONG_TW
|
||||
- MODE_LONG_SZ
|
||||
- MODE_LONG_TC
|
||||
- MODE_LONG_RM
|
||||
- MODE_LONG_CB
|
||||
|
||||
</details>
|
||||
|
||||
### 🟢 user.json
|
||||
|
||||
@@ -116,7 +141,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**59/61**
|
||||
**73/75**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -147,7 +172,7 @@
|
||||
|
||||
### 🟢 game-misc.json
|
||||
|
||||
**17/17**
|
||||
**22/22**
|
||||
|
||||
### 🟢 user.json
|
||||
|
||||
@@ -205,7 +230,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**48/61**
|
||||
**48/75**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -219,10 +244,24 @@
|
||||
- auth.errors.unknown
|
||||
- actions.copyToClipboard
|
||||
- actions.close
|
||||
- actions.loading
|
||||
- actions.clear
|
||||
- actions.selectAll
|
||||
- actions.search
|
||||
- maps.createMapList
|
||||
- maps.halfSz
|
||||
- maps.mapPool
|
||||
- maps.tournamentMaplist
|
||||
- maps.template
|
||||
- maps.template.none
|
||||
- maps.template.event
|
||||
- maps.template.presets
|
||||
- maps.template.yourRecentEvents
|
||||
- maps.template.preset.ANARCHY
|
||||
- maps.template.preset.ALL
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
|
||||
</details>
|
||||
|
||||
@@ -248,7 +287,7 @@
|
||||
|
||||
### 🟡 game-misc.json
|
||||
|
||||
**12/17**
|
||||
**12/22**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -258,6 +297,11 @@
|
||||
- MODE_SHORT_TC
|
||||
- MODE_SHORT_RM
|
||||
- MODE_SHORT_CB
|
||||
- MODE_LONG_TW
|
||||
- MODE_LONG_SZ
|
||||
- MODE_LONG_TC
|
||||
- MODE_LONG_RM
|
||||
- MODE_LONG_CB
|
||||
|
||||
</details>
|
||||
|
||||
@@ -341,7 +385,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**46/61**
|
||||
**46/75**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -357,10 +401,24 @@
|
||||
- actions.loadMore
|
||||
- actions.copyToClipboard
|
||||
- actions.close
|
||||
- actions.loading
|
||||
- actions.clear
|
||||
- actions.selectAll
|
||||
- actions.search
|
||||
- maps.createMapList
|
||||
- maps.halfSz
|
||||
- maps.mapPool
|
||||
- maps.tournamentMaplist
|
||||
- maps.template
|
||||
- maps.template.none
|
||||
- maps.template.event
|
||||
- maps.template.presets
|
||||
- maps.template.yourRecentEvents
|
||||
- maps.template.preset.ANARCHY
|
||||
- maps.template.preset.ALL
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
|
||||
</details>
|
||||
|
||||
@@ -396,7 +454,7 @@
|
||||
|
||||
### 🟡 game-misc.json
|
||||
|
||||
**12/17**
|
||||
**12/22**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -406,6 +464,11 @@
|
||||
- MODE_SHORT_TC
|
||||
- MODE_SHORT_RM
|
||||
- MODE_SHORT_CB
|
||||
- MODE_LONG_TW
|
||||
- MODE_LONG_SZ
|
||||
- MODE_LONG_TC
|
||||
- MODE_LONG_RM
|
||||
- MODE_LONG_CB
|
||||
|
||||
</details>
|
||||
|
||||
@@ -459,7 +522,7 @@
|
||||
|
||||
### 🔴 common.json
|
||||
|
||||
**0/61**
|
||||
**0/75**
|
||||
|
||||
### 🔴 contributions.json
|
||||
|
||||
@@ -475,7 +538,7 @@
|
||||
|
||||
### 🟡 game-misc.json
|
||||
|
||||
**12/17**
|
||||
**12/22**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -485,6 +548,11 @@
|
||||
- MODE_SHORT_TC
|
||||
- MODE_SHORT_RM
|
||||
- MODE_SHORT_CB
|
||||
- MODE_LONG_TW
|
||||
- MODE_LONG_SZ
|
||||
- MODE_LONG_TC
|
||||
- MODE_LONG_RM
|
||||
- MODE_LONG_CB
|
||||
|
||||
</details>
|
||||
|
||||
@@ -623,7 +691,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**46/61**
|
||||
**46/75**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -639,10 +707,24 @@
|
||||
- actions.loadMore
|
||||
- actions.copyToClipboard
|
||||
- actions.close
|
||||
- actions.loading
|
||||
- actions.clear
|
||||
- actions.selectAll
|
||||
- actions.search
|
||||
- maps.createMapList
|
||||
- maps.halfSz
|
||||
- maps.mapPool
|
||||
- maps.tournamentMaplist
|
||||
- maps.template
|
||||
- maps.template.none
|
||||
- maps.template.event
|
||||
- maps.template.presets
|
||||
- maps.template.yourRecentEvents
|
||||
- maps.template.preset.ANARCHY
|
||||
- maps.template.preset.ALL
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
|
||||
</details>
|
||||
|
||||
@@ -677,7 +759,7 @@
|
||||
|
||||
### 🟡 game-misc.json
|
||||
|
||||
**12/17**
|
||||
**12/22**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -687,6 +769,11 @@
|
||||
- MODE_SHORT_TC
|
||||
- MODE_SHORT_RM
|
||||
- MODE_SHORT_CB
|
||||
- MODE_LONG_TW
|
||||
- MODE_LONG_SZ
|
||||
- MODE_LONG_TC
|
||||
- MODE_LONG_RM
|
||||
- MODE_LONG_CB
|
||||
|
||||
</details>
|
||||
|
||||
@@ -748,7 +835,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**35/61**
|
||||
**35/75**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -764,10 +851,24 @@
|
||||
- actions.loadMore
|
||||
- actions.copyToClipboard
|
||||
- actions.close
|
||||
- actions.loading
|
||||
- actions.clear
|
||||
- actions.selectAll
|
||||
- actions.search
|
||||
- maps.createMapList
|
||||
- maps.halfSz
|
||||
- maps.mapPool
|
||||
- maps.tournamentMaplist
|
||||
- maps.template
|
||||
- maps.template.none
|
||||
- maps.template.event
|
||||
- maps.template.presets
|
||||
- maps.template.yourRecentEvents
|
||||
- maps.template.preset.ANARCHY
|
||||
- maps.template.preset.ALL
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
- weapon.category.SHOOTERS
|
||||
- weapon.category.BLASTERS
|
||||
- weapon.category.ROLLERS
|
||||
@@ -813,7 +914,7 @@
|
||||
|
||||
### 🟡 game-misc.json
|
||||
|
||||
**12/17**
|
||||
**12/22**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -823,6 +924,11 @@
|
||||
- MODE_SHORT_TC
|
||||
- MODE_SHORT_RM
|
||||
- MODE_SHORT_CB
|
||||
- MODE_LONG_TW
|
||||
- MODE_LONG_SZ
|
||||
- MODE_LONG_TC
|
||||
- MODE_LONG_RM
|
||||
- MODE_LONG_CB
|
||||
|
||||
</details>
|
||||
|
||||
@@ -903,7 +1009,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**48/61**
|
||||
**48/75**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -917,10 +1023,24 @@
|
||||
- auth.errors.unknown
|
||||
- actions.copyToClipboard
|
||||
- actions.close
|
||||
- actions.loading
|
||||
- actions.clear
|
||||
- actions.selectAll
|
||||
- actions.search
|
||||
- maps.createMapList
|
||||
- maps.halfSz
|
||||
- maps.mapPool
|
||||
- maps.tournamentMaplist
|
||||
- maps.template
|
||||
- maps.template.none
|
||||
- maps.template.event
|
||||
- maps.template.presets
|
||||
- maps.template.yourRecentEvents
|
||||
- maps.template.preset.ANARCHY
|
||||
- maps.template.preset.ALL
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
|
||||
</details>
|
||||
|
||||
@@ -946,7 +1066,7 @@
|
||||
|
||||
### 🟡 game-misc.json
|
||||
|
||||
**12/17**
|
||||
**12/22**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -956,6 +1076,11 @@
|
||||
- MODE_SHORT_TC
|
||||
- MODE_SHORT_RM
|
||||
- MODE_SHORT_CB
|
||||
- MODE_LONG_TW
|
||||
- MODE_LONG_SZ
|
||||
- MODE_LONG_TC
|
||||
- MODE_LONG_RM
|
||||
- MODE_LONG_CB
|
||||
|
||||
</details>
|
||||
|
||||
@@ -979,9 +1104,21 @@
|
||||
|
||||
## /ru (🟡 In progress)
|
||||
|
||||
### 🔴 analyzer.json
|
||||
### 🟡 analyzer.json
|
||||
|
||||
**0/107**
|
||||
**101/107**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- objCalcAd
|
||||
- stat.specialLostSplattedByRP
|
||||
- stat.quickRespawnTimeSplattedByRP
|
||||
- damage.NORMAL_MAX_FULL_CHARGE
|
||||
- dmgHtdExplanation
|
||||
- noDmgData
|
||||
|
||||
</details>
|
||||
|
||||
### 🟢 badges.json
|
||||
|
||||
@@ -991,64 +1128,38 @@
|
||||
|
||||
**11/11**
|
||||
|
||||
### 🟡 calendar.json
|
||||
### 🟢 calendar.json
|
||||
|
||||
**44/46**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- createMapList
|
||||
- forms.mapPool
|
||||
|
||||
</details>
|
||||
**46/46**
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**35/61**
|
||||
**60/75**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- pages.s2
|
||||
- pages.analyzer
|
||||
- pages.maps
|
||||
- pages.object-damage-calculator
|
||||
- auth.errors.aborted
|
||||
- auth.errors.failed
|
||||
- auth.errors.discordPermissions
|
||||
- auth.errors.unknown
|
||||
- actions.loadMore
|
||||
- actions.copyToClipboard
|
||||
- actions.close
|
||||
- maps.createMapList
|
||||
- maps.halfSz
|
||||
- maps.mapPool
|
||||
- maps.tournamentMaplist
|
||||
- weapon.category.SHOOTERS
|
||||
- weapon.category.BLASTERS
|
||||
- weapon.category.ROLLERS
|
||||
- weapon.category.BRUSHES
|
||||
- weapon.category.CHARGERS
|
||||
- weapon.category.SLOSHERS
|
||||
- weapon.category.SPLATLINGS
|
||||
- weapon.category.DUALIES
|
||||
- weapon.category.BRELLAS
|
||||
- weapon.category.STRINGERS
|
||||
- weapon.category.SPLATANAS
|
||||
- actions.loading
|
||||
- actions.clear
|
||||
- actions.selectAll
|
||||
- actions.search
|
||||
- maps.template
|
||||
- maps.template.none
|
||||
- maps.template.event
|
||||
- maps.template.presets
|
||||
- maps.template.yourRecentEvents
|
||||
- maps.template.preset.ANARCHY
|
||||
- maps.template.preset.ALL
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
|
||||
</details>
|
||||
|
||||
### 🟡 contributions.json
|
||||
### 🟢 contributions.json
|
||||
|
||||
**5/6**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- translation
|
||||
|
||||
</details>
|
||||
**6/6**
|
||||
|
||||
### 🟢 faq.json
|
||||
|
||||
@@ -1056,60 +1167,33 @@
|
||||
|
||||
### 🟡 front.json
|
||||
|
||||
**8/12**
|
||||
**11/12**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- buildsGoTo
|
||||
- analyzer.description
|
||||
- maps.description
|
||||
- object-damage-calculator.description
|
||||
|
||||
</details>
|
||||
|
||||
### 🟡 game-misc.json
|
||||
|
||||
**12/17**
|
||||
**17/22**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- MODE_SHORT_TW
|
||||
- MODE_SHORT_SZ
|
||||
- MODE_SHORT_TC
|
||||
- MODE_SHORT_RM
|
||||
- MODE_SHORT_CB
|
||||
- MODE_LONG_TW
|
||||
- MODE_LONG_SZ
|
||||
- MODE_LONG_TC
|
||||
- MODE_LONG_RM
|
||||
- MODE_LONG_CB
|
||||
|
||||
</details>
|
||||
|
||||
### 🟡 user.json
|
||||
### 🟢 user.json
|
||||
|
||||
**7/25**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- customUrl
|
||||
- ign
|
||||
- ign.short
|
||||
- stickSens
|
||||
- motionSens
|
||||
- motion
|
||||
- stick
|
||||
- sens
|
||||
- results.title
|
||||
- results.participants
|
||||
- results.highlights
|
||||
- results.nonHighlights
|
||||
- results.highlights.choose
|
||||
- results.highlights.explanation
|
||||
- forms.errors.invalidCustomUrl.numbers
|
||||
- forms.errors.invalidCustomUrl.strangeCharacter
|
||||
- forms.errors.invalidCustomUrl.duplicate
|
||||
- forms.errors.invalidSens
|
||||
|
||||
</details>
|
||||
**25/25**
|
||||
|
||||
---
|
||||
|
||||
@@ -1160,7 +1244,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**35/61**
|
||||
**35/75**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -1176,10 +1260,24 @@
|
||||
- actions.loadMore
|
||||
- actions.copyToClipboard
|
||||
- actions.close
|
||||
- actions.loading
|
||||
- actions.clear
|
||||
- actions.selectAll
|
||||
- actions.search
|
||||
- maps.createMapList
|
||||
- maps.halfSz
|
||||
- maps.mapPool
|
||||
- maps.tournamentMaplist
|
||||
- maps.template
|
||||
- maps.template.none
|
||||
- maps.template.event
|
||||
- maps.template.presets
|
||||
- maps.template.yourRecentEvents
|
||||
- maps.template.preset.ANARCHY
|
||||
- maps.template.preset.ALL
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
- weapon.category.SHOOTERS
|
||||
- weapon.category.BLASTERS
|
||||
- weapon.category.ROLLERS
|
||||
@@ -1225,7 +1323,7 @@
|
||||
|
||||
### 🟡 game-misc.json
|
||||
|
||||
**12/17**
|
||||
**12/22**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -1235,6 +1333,11 @@
|
||||
- MODE_SHORT_TC
|
||||
- MODE_SHORT_RM
|
||||
- MODE_SHORT_CB
|
||||
- MODE_LONG_TW
|
||||
- MODE_LONG_SZ
|
||||
- MODE_LONG_TC
|
||||
- MODE_LONG_RM
|
||||
- MODE_LONG_CB
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user