Merge pull request #1047 from Sendouc/map-pool-templates

Map pool templates
This commit is contained in:
Kalle
2022-10-27 20:29:35 +03:00
committed by GitHub
33 changed files with 1340 additions and 531 deletions

View File

@@ -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
/>
);
}

View 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>
);
}

View 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>
);
}

View 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

View 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

View File

@@ -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)),
}));
}

View File

@@ -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
View 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, []);
}

View File

@@ -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);

View File

@@ -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++) {

View File

@@ -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: [],

View File

@@ -1,6 +1,2 @@
export {
mapPoolToSerializedString,
serializedStringToMapPool,
} from "./serializer";
export type { MapPool } from "./types";
export { MapPool } from "./map-pool";
export type { MapPoolObject } from "./types";

View 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],
});
}

View File

@@ -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: [],

View File

@@ -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

View File

@@ -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[]>
>;

View File

@@ -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
>

View File

@@ -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>
);
}

View 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];

View File

@@ -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>
);
}

View File

@@ -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>
))}

View File

@@ -1,5 +1,5 @@
.calendar-new__container {
max-width: 32rem;
max-width: 38rem;
}
.calendar-new__select {

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -126,6 +126,10 @@
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.flex-wrap {
flex-wrap: wrap;
}

View File

@@ -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 {

View File

@@ -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>;
}

View File

@@ -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;

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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>