-
-
-
-
-
-
- ${translateText("map.map")}
-
-
-
-
- this.handleMapSelection(mapValue)}
- .onSelectRandom=${() => this.handleSelectRandomMap()}
- >
-
-
-
-
-
-
-
- ${translateText("difficulty.difficulty")}
-
-
-
-
- ${Object.entries(Difficulty)
- .filter(([key]) => isNaN(Number(key)))
- .map(
- ([key, value]) => html`
-
- `,
- )}
-
-
-
-
-
-
-
-
- ${translateText("host_modal.mode")}
-
-
-
-
- ${[GameMode.FFA, GameMode.Team].map((mode) => {
- const isSelected = this.gameMode === mode;
- const label =
- mode === GameMode.FFA
- ? translateText("game_mode.ffa")
- : translateText("game_mode.teams");
-
- return html`
-
- `;
- })}
-
-
-
- ${this.gameMode === GameMode.FFA
- ? ""
- : html`
-
-
-
- ${translateText("host_modal.team_count")}
-
-
- ${[
- 2,
- 3,
- 4,
- 5,
- 6,
- 7,
- Quads,
- Trios,
- Duos,
- HumansVsNations,
- ].map(
- (o) => html`
-
- `,
- )}
-
-
- `}
-
-
-
-
-
-
- ${translateText("single_modal.options_title")}
-
-
-
-
-
-
-
-
-
- ${this.renderOptionToggle(
- "single_modal.disable_nations",
- this.disableNations,
- (val) => (this.disableNations = val),
- this.gameMode === GameMode.Team &&
- this.teamCount === HumansVsNations,
- )}
- ${this.renderOptionToggle(
- "single_modal.instant_build",
- this.instantBuild,
- (val) => (this.instantBuild = val),
- )}
- ${this.renderOptionToggle(
- "single_modal.random_spawn",
- this.randomSpawn,
- (val) => (this.randomSpawn = val),
- )}
- ${this.renderOptionToggle(
- "single_modal.infinite_gold",
- this.infiniteGold,
- (val) => (this.infiniteGold = val),
- )}
- ${this.renderOptionToggle(
- "single_modal.infinite_troops",
- this.infiniteTroops,
- (val) => (this.infiniteTroops = val),
- )}
- ${this.renderOptionToggle(
- "single_modal.compact_map",
- this.compactMap,
- (val) => {
- this.compactMap = val;
- if (val && this.bots === 400) {
- this.bots = 100;
- } else if (!val && this.bots === 100) {
- this.bots = 400;
- }
+
+ {
- this.maxTimer = !this.maxTimer;
- if (!this.maxTimer) {
- this.maxTimerValue = undefined;
- } else {
- // Set default value when enabling if not already set or invalid
- if (!this.maxTimerValue || this.maxTimerValue <= 0) {
- this.maxTimerValue = 30;
- }
- // Focus the input after render
- setTimeout(() => {
- const input = this.getEndTimerInput();
- if (input) {
- input.focus();
- input.select();
- }
- }, 0);
- }
+ {
+ labelKey: "single_modal.instant_build",
+ checked: this.instantBuild,
},
- input: renderToggleInputCardInput({
- id: "end-timer-value",
- min: 1,
- max: 120,
- value: this.maxTimerValue ?? "",
- ariaLabel: translateText("single_modal.max_timer"),
- placeholder: translateText(
- "single_modal.max_timer_placeholder",
- ),
- onInput: this.handleMaxTimerValueChanges,
- onKeyDown: this.handleMaxTimerValueKeyDown,
- }),
- })}
-
-
- ${renderToggleInputCard({
- labelKey: "single_modal.gold_multiplier",
- checked: this.goldMultiplier,
- onClick: () => {
- this.goldMultiplier = !this.goldMultiplier;
- if (!this.goldMultiplier) {
- this.goldMultiplierValue = undefined;
- } else {
- if (
- !this.goldMultiplierValue ||
- this.goldMultiplierValue <= 0
- ) {
- this.goldMultiplierValue = 2;
- }
- setTimeout(() => {
- const input = this.renderRoot.querySelector(
- "#gold-multiplier-value",
- ) as HTMLInputElement;
- if (input) {
- input.focus();
- input.select();
- }
- }, 0);
- }
+ {
+ labelKey: "single_modal.random_spawn",
+ checked: this.randomSpawn,
},
- input: renderToggleInputCardInput({
- id: "gold-multiplier-value",
- min: 0.1,
- max: 1000,
- step: "any",
- value: this.goldMultiplierValue ?? "",
- ariaLabel: translateText("single_modal.gold_multiplier"),
- placeholder: translateText(
- "single_modal.gold_multiplier_placeholder",
- ),
- onChange: this.handleGoldMultiplierValueChanges,
- onKeyDown: this.handleGoldMultiplierValueKeyDown,
- }),
- })}
-
-
- ${renderToggleInputCard({
- labelKey: "single_modal.starting_gold",
- checked: this.startingGold,
- onClick: () => {
- this.startingGold = !this.startingGold;
- if (!this.startingGold) {
- this.startingGoldValue = undefined;
- } else {
- if (
- !this.startingGoldValue ||
- this.startingGoldValue < 0
- ) {
- this.startingGoldValue = 5000000;
- }
- setTimeout(() => {
- const input = this.renderRoot.querySelector(
- "#starting-gold-value",
- ) as HTMLInputElement;
- if (input) {
- input.focus();
- input.select();
- }
- }, 0);
- }
+ {
+ labelKey: "single_modal.infinite_gold",
+ checked: this.infiniteGold,
},
- input: renderToggleInputCardInput({
- id: "starting-gold-value",
- min: 0,
- max: 1000000000,
- step: 100000,
- value: this.startingGoldValue ?? "",
- ariaLabel: translateText("single_modal.starting_gold"),
- placeholder: translateText(
- "single_modal.starting_gold_placeholder",
- ),
- onInput: this.handleStartingGoldValueChanges,
- onKeyDown: this.handleStartingGoldValueKeyDown,
- }),
- })}
-
-
-
-
-
-
-
-
- ${translateText("single_modal.enables_title")}
-
-
-
- ${renderUnitTypeOptions({
- disabledUnits: this.disabledUnits,
- toggleUnit: this.toggleUnit.bind(this),
- })}
-
-
-
+ {
+ labelKey: "single_modal.infinite_troops",
+ checked: this.infiniteTroops,
+ },
+ {
+ labelKey: "single_modal.compact_map",
+ checked: this.compactMap,
+ },
+ ],
+ inputCards,
+ },
+ unitTypes: {
+ titleKey: "single_modal.enables_title",
+ disabledUnits: this.disabledUnits,
+ },
+ }}
+ @map-selected=${this.handleConfigMapSelected}
+ @random-map-selected=${this.handleConfigRandomMapSelected}
+ @difficulty-selected=${this.handleConfigDifficultySelected}
+ @game-mode-selected=${this.handleConfigGameModeSelected}
+ @team-count-selected=${this.handleConfigTeamCountSelected}
+ @bots-changed=${this.handleBotsChange}
+ @option-toggle-changed=${this.handleConfigOptionToggleChanged}
+ @unit-toggle-changed=${this.handleConfigUnitToggleChanged}
+ >
@@ -698,33 +383,6 @@ export class SinglePlayerModal extends BaseModal {
);
}
- // Helper for consistent option buttons
- private renderOptionToggle(
- labelKey: string,
- checked: boolean,
- onChange: (val: boolean) => void,
- hidden: boolean = false,
- ): TemplateResult {
- if (hidden) return html``;
-
- return html`
-
- `;
- }
-
protected onClose(): void {
// Reset all transient form state to ensure clean slate
this.selectedMap = DEFAULT_OPTIONS.selectedMap;
@@ -752,29 +410,121 @@ export class SinglePlayerModal extends BaseModal {
this.useRandomMap = true;
}
+ private handleConfigRandomMapSelected = () => {
+ this.handleSelectRandomMap();
+ };
+
private handleMapSelection(value: GameMapType) {
this.selectedMap = value;
this.useRandomMap = false;
}
+ private handleConfigMapSelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ map: GameMapType }>;
+ this.handleMapSelection(customEvent.detail.map);
+ };
+
private handleDifficultySelection(value: Difficulty) {
this.selectedDifficulty = value;
}
- private handleBotsChange(e: Event) {
+ private handleConfigDifficultySelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ difficulty: Difficulty }>;
+ this.handleDifficultySelection(customEvent.detail.difficulty);
+ };
+
+ private handleConfigGameModeSelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ mode: GameMode }>;
+ this.handleGameModeSelection(customEvent.detail.mode);
+ };
+
+ private handleConfigTeamCountSelected = (e: Event) => {
+ const customEvent = e as CustomEvent<{ count: TeamCountConfig }>;
+ this.handleTeamCountSelection(customEvent.detail.count);
+ };
+
+ private handleCompactMapChange(val: boolean) {
+ this.compactMap = val;
+ this.bots = getBotsForCompactMap(this.bots, val);
+ }
+
+ private handleConfigOptionToggleChanged = (e: Event) => {
+ const customEvent = e as CustomEvent<{
+ labelKey: string;
+ checked: boolean;
+ }>;
+ const { labelKey, checked } = customEvent.detail;
+
+ switch (labelKey) {
+ case "single_modal.disable_nations":
+ this.disableNations = checked;
+ break;
+ case "single_modal.instant_build":
+ this.instantBuild = checked;
+ break;
+ case "single_modal.random_spawn":
+ this.randomSpawn = checked;
+ break;
+ case "single_modal.infinite_gold":
+ this.infiniteGold = checked;
+ break;
+ case "single_modal.infinite_troops":
+ this.infiniteTroops = checked;
+ break;
+ case "single_modal.compact_map":
+ this.handleCompactMapChange(checked);
+ break;
+ default:
+ break;
+ }
+ };
+
+ private handleConfigUnitToggleChanged = (e: Event) => {
+ const customEvent = e as CustomEvent<{ unit: UnitType; checked: boolean }>;
+ const { unit, checked } = customEvent.detail;
+ this.disabledUnits = getUpdatedDisabledUnits(
+ this.disabledUnits,
+ unit,
+ checked,
+ );
+ };
+
+ private handleBotsChange = (e: Event) => {
const customEvent = e as CustomEvent<{ value: number }>;
const value = customEvent.detail.value;
if (isNaN(value) || value < 0 || value > 400) {
return;
}
this.bots = value;
- }
+ };
- private handleMaxTimerValueKeyDown(e: KeyboardEvent) {
- if (["-", "+", "e"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleMaxTimerToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.maxTimer = checked;
+ this.maxTimerValue = toOptionalNumber(value);
+ };
+
+ private handleGoldMultiplierToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.goldMultiplier = checked;
+ this.goldMultiplierValue = toOptionalNumber(value);
+ };
+
+ private handleStartingGoldToggle = (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => {
+ this.startingGold = checked;
+ this.startingGoldValue = toOptionalNumber(value);
+ };
+
+ private handleMaxTimerValueKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e"]);
+ };
private getEndTimerInput(): HTMLInputElement | null {
return (
@@ -785,55 +535,46 @@ export class SinglePlayerModal extends BaseModal {
);
}
- private handleMaxTimerValueChanges(e: Event) {
+ private handleMaxTimerValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
- input.value = input.value.replace(/[e+-]/gi, "");
- const value = parseInt(input.value);
+ const value = parseBoundedIntegerFromInput(input, {
+ min: 1,
+ max: 120,
+ stripPattern: /[e+-]/gi,
+ });
- // Always update state to keep UI and internal state in sync
- if (isNaN(value) || value < 1 || value > 120) {
- // Set to undefined for invalid/empty/out-of-range values
- this.maxTimerValue = undefined;
- } else {
- this.maxTimerValue = value;
- }
- }
+ this.maxTimerValue = value;
+ };
- private handleGoldMultiplierValueKeyDown(e: KeyboardEvent) {
- if (["+", "-", "e", "E"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleGoldMultiplierValueKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["+", "-", "e", "E"]);
+ };
- private handleGoldMultiplierValueChanges(e: Event) {
+ private handleGoldMultiplierValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
- const value = parseFloat(input.value);
+ const value = parseBoundedFloatFromInput(input, { min: 0.1, max: 1000 });
- if (isNaN(value) || value < 0.1 || value > 1000) {
+ if (value === undefined) {
this.goldMultiplierValue = undefined;
input.value = "";
} else {
this.goldMultiplierValue = value;
}
- }
+ };
- private handleStartingGoldValueKeyDown(e: KeyboardEvent) {
- if (["-", "+", "e", "E"].includes(e.key)) {
- e.preventDefault();
- }
- }
+ private handleStartingGoldValueKeyDown = (e: KeyboardEvent) => {
+ preventDisallowedKeys(e, ["-", "+", "e", "E"]);
+ };
- private handleStartingGoldValueChanges(e: Event) {
+ private handleStartingGoldValueChanges = (e: Event) => {
const input = e.target as HTMLInputElement;
- input.value = input.value.replace(/[eE+-]/g, "");
- const value = parseInt(input.value);
+ const value = parseBoundedIntegerFromInput(input, {
+ min: 0,
+ max: 1000000000,
+ });
- if (isNaN(value) || value < 0 || value > 1000000000) {
- this.startingGoldValue = undefined;
- } else {
- this.startingGoldValue = value;
- }
- }
+ this.startingGoldValue = value;
+ };
private handleGameModeSelection(value: GameMode) {
this.gameMode = value;
@@ -843,18 +584,6 @@ export class SinglePlayerModal extends BaseModal {
this.teamCount = value;
}
- private getRandomMap(): GameMapType {
- const maps = Object.values(GameMapType);
- const randIdx = Math.floor(Math.random() * maps.length);
- return maps[randIdx] as GameMapType;
- }
-
- private toggleUnit(unit: UnitType, checked: boolean): void {
- this.disabledUnits = checked
- ? [...this.disabledUnits, unit]
- : this.disabledUnits.filter((u) => u !== unit);
- }
-
private async startGame() {
// Validate and clamp maxTimer setting before starting
let finalMaxTimerValue: number | undefined = undefined;
@@ -879,7 +608,7 @@ export class SinglePlayerModal extends BaseModal {
// If random map is selected, choose a random map now
if (this.useRandomMap) {
- this.selectedMap = this.getRandomMap();
+ this.selectedMap = getRandomMapType();
}
console.log(
diff --git a/src/client/components/GameConfigSettings.ts b/src/client/components/GameConfigSettings.ts
new file mode 100644
index 000000000..b981a89fe
--- /dev/null
+++ b/src/client/components/GameConfigSettings.ts
@@ -0,0 +1,447 @@
+import {
+ LitElement,
+ SVGTemplateResult,
+ TemplateResult,
+ html,
+ nothing,
+ svg,
+} from "lit";
+import { customElement, property } from "lit/decorators.js";
+import {
+ Difficulty,
+ Duos,
+ GameMapType,
+ GameMode,
+ HumansVsNations,
+ Quads,
+ Trios,
+ UnitType,
+} from "../../core/game/Game";
+import { TeamCountConfig } from "../../core/Schemas";
+import { translateText } from "../Utils";
+import "./Difficulties";
+import "./FluentSlider";
+import "./map/MapPicker";
+
+const ACTIVE_CARD =
+ "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
+const INACTIVE_CARD =
+ "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
+
+const DISABLED_CARD =
+ "w-full rounded-xl border transition-all duration-200 opacity-30 grayscale cursor-not-allowed bg-white/5 border-white/5";
+
+function cardClass(active: boolean, extra = ""): string {
+ return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
+}
+
+const CARD_LABEL_CLASS =
+ "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
+
+const DIFFICULTY_OPTIONS = Object.entries(Difficulty).filter(([key]) =>
+ isNaN(Number(key)),
+) as Array<[string, Difficulty]>;
+const TEAM_COUNT_OPTIONS: TeamCountConfig[] = [
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ Quads,
+ Trios,
+ Duos,
+ HumansVsNations,
+];
+
+function stateTextClass(active: boolean): string {
+ return active ? "text-white" : "text-white/60";
+}
+
+function renderTextCardButton(
+ label: string,
+ active: boolean,
+ onClick: () => void,
+ cardExtraClass: string,
+): TemplateResult {
+ return html`
+
+ `;
+}
+
+function renderSection(
+ iconSvg: SVGTemplateResult,
+ colorClass: string,
+ bgClass: string,
+ titleKey: string,
+ content: TemplateResult | TemplateResult[],
+ sectionClass = "space-y-6",
+): TemplateResult {
+ return html`
+
+ ${renderSectionHeader(iconSvg, colorClass, bgClass, titleKey)} ${content}
+
+ `;
+}
+
+const unitOptions: { type: UnitType; translationKey: string }[] = [
+ { type: UnitType.City, translationKey: "unit_type.city" },
+ { type: UnitType.DefensePost, translationKey: "unit_type.defense_post" },
+ { type: UnitType.Port, translationKey: "unit_type.port" },
+ { type: UnitType.Warship, translationKey: "unit_type.warship" },
+ { type: UnitType.MissileSilo, translationKey: "unit_type.missile_silo" },
+ { type: UnitType.SAMLauncher, translationKey: "unit_type.sam_launcher" },
+ { type: UnitType.AtomBomb, translationKey: "unit_type.atom_bomb" },
+ { type: UnitType.HydrogenBomb, translationKey: "unit_type.hydrogen_bomb" },
+ { type: UnitType.MIRV, translationKey: "unit_type.mirv" },
+ { type: UnitType.Factory, translationKey: "unit_type.factory" },
+];
+
+const MAP_ICON = svg`
`;
+
+const DIFFICULTY_ICON = svg`
`;
+
+const MODE_ICON = svg`
`;
+
+const OPTIONS_ICON = svg`
`;
+
+const ENABLES_ICON = svg`
`;
+
+function renderSectionHeader(
+ iconSvg: SVGTemplateResult,
+ colorClass: string,
+ bgClass: string,
+ titleKey: string,
+): TemplateResult {
+ return html`
+
+
+
+
+
+ ${translateText(titleKey)}
+
+
+ `;
+}
+
+export interface ToggleOptionConfig {
+ labelKey: string;
+ checked: boolean;
+ hidden?: boolean;
+}
+
+export interface GameConfigSettingsData {
+ map: {
+ selected: GameMapType;
+ useRandom: boolean;
+ randomMapDivider?: boolean;
+ showMedals?: boolean;
+ mapWins?: Map
>;
+ };
+ difficulty: {
+ selected: Difficulty;
+ disabled: boolean;
+ };
+ gameMode: {
+ selected: GameMode;
+ };
+ teamCount: {
+ selected: TeamCountConfig;
+ };
+ options: {
+ titleKey: string;
+ bots: {
+ value: number;
+ labelKey: string;
+ disabledKey: string;
+ };
+ toggles: ToggleOptionConfig[];
+ inputCards: TemplateResult[];
+ };
+ unitTypes: {
+ titleKey: string;
+ disabledUnits: UnitType[];
+ };
+}
+
+@customElement("game-config-settings")
+export class GameConfigSettings extends LitElement {
+ @property({ attribute: false }) settings?: GameConfigSettingsData;
+ @property({ attribute: false }) sectionGapClass = "space-y-6";
+
+ createRenderRoot() {
+ return this;
+ }
+
+ private emit(name: string, detail: T) {
+ this.dispatchEvent(
+ new CustomEvent(name, {
+ detail,
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ private handleSelectMap = (map: GameMapType) => {
+ this.emit("map-selected", { map });
+ };
+
+ private handleSelectRandom = () => {
+ this.emit("random-map-selected", {});
+ };
+
+ private handleDifficultySelect = (difficulty: Difficulty) => {
+ this.emit("difficulty-selected", { difficulty });
+ };
+
+ private handleGameModeSelect = (mode: GameMode) => {
+ this.emit("game-mode-selected", { mode });
+ };
+
+ private handleTeamCountSelect = (count: TeamCountConfig) => {
+ this.emit("team-count-selected", { count });
+ };
+
+ private handleOptionToggle = (toggle: ToggleOptionConfig) => {
+ this.emit("option-toggle-changed", {
+ labelKey: toggle.labelKey,
+ checked: !toggle.checked,
+ });
+ };
+
+ private handleBotsChanged = (event: Event) => {
+ const customEvent = event as CustomEvent<{ value: number }>;
+ this.emit("bots-changed", customEvent.detail);
+ };
+
+ private handleUnitToggle = (unit: UnitType, checked: boolean) => {
+ this.emit("unit-toggle-changed", { unit, checked });
+ };
+
+ private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult {
+ if (toggle.hidden) return html``;
+
+ return renderTextCardButton(
+ translateText(toggle.labelKey),
+ toggle.checked,
+ () => this.handleOptionToggle(toggle),
+ "p-4 text-center",
+ );
+ }
+
+ private renderUnitTypeOptions(disabledUnits: UnitType[]): TemplateResult[] {
+ return unitOptions.map(({ type, translationKey }) => {
+ const isEnabled = !disabledUnits.includes(type);
+ return html`
+
+ `;
+ });
+ }
+
+ render() {
+ if (!this.settings) return nothing;
+ const settings = this.settings;
+
+ return html`
+
+ ${renderSection(
+ MAP_ICON,
+ "text-blue-400",
+ "bg-blue-500/20",
+ "map.map",
+ html`
`,
+ )}
+ ${renderSection(
+ DIFFICULTY_ICON,
+ "text-green-400",
+ "bg-green-500/20",
+ "difficulty.difficulty",
+ html`
+
+ ${DIFFICULTY_OPTIONS.map(([key, value]) => {
+ const isSelected = settings.difficulty.selected === value;
+ const isDisabled = settings.difficulty.disabled;
+ return html`
+
+ `;
+ })}
+
+ `,
+ )}
+ ${renderSection(
+ MODE_ICON,
+ "text-purple-400",
+ "bg-purple-500/20",
+ "host_modal.mode",
+ html`
+
+ ${[GameMode.FFA, GameMode.Team].map((mode) => {
+ const isSelected = settings.gameMode.selected === mode;
+ return html`
+
+ `;
+ })}
+
+ `,
+ )}
+ ${settings.gameMode.selected === GameMode.FFA
+ ? nothing
+ : html`
+
+
+ ${translateText("host_modal.team_count")}
+
+
+ ${TEAM_COUNT_OPTIONS.map((o) => {
+ const isSelected = settings.teamCount.selected === o;
+ return html`
+
+ `;
+ })}
+
+
+ `}
+ ${renderSection(
+ OPTIONS_ICON,
+ "text-orange-400",
+ "bg-orange-500/20",
+ settings.options.titleKey,
+ html`
+
+
+
+
+
+ ${settings.options.toggles.map((toggle) =>
+ this.renderOptionToggle(toggle),
+ )}
+ ${settings.options.inputCards}
+
+ `,
+ )}
+ ${renderSection(
+ ENABLES_ICON,
+ "text-teal-400",
+ "bg-teal-500/20",
+ settings.unitTypes.titleKey,
+ html`
+
+ ${this.renderUnitTypeOptions(settings.unitTypes.disabledUnits)}
+
+ `,
+ "space-y-6 pb-6",
+ )}
+
+ `;
+ }
+}
diff --git a/src/client/components/ToggleInputCard.ts b/src/client/components/ToggleInputCard.ts
new file mode 100644
index 000000000..279366aff
--- /dev/null
+++ b/src/client/components/ToggleInputCard.ts
@@ -0,0 +1,173 @@
+import { LitElement, PropertyValues, html, nothing } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { translateText } from "../Utils";
+
+const ACTIVE_CARD =
+ "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.2)]";
+const INACTIVE_CARD =
+ "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20";
+const INPUT_CLASS =
+ "w-full text-center rounded bg-black/60 text-white text-sm font-bold border border-white/20 focus:outline-none focus:border-blue-500 p-1 my-1";
+const CARD_LABEL_CLASS =
+ "text-xs uppercase font-bold tracking-wider leading-tight break-words hyphens-auto";
+
+function cardClass(active: boolean, extra = ""): string {
+ return `w-full rounded-xl border cursor-pointer transition-all duration-200 active:scale-95 ${extra} ${active ? ACTIVE_CARD : INACTIVE_CARD}`;
+}
+
+@customElement("toggle-input-card")
+export class ToggleInputCard extends LitElement {
+ @property({ attribute: false }) labelKey = "";
+ @property({ type: Boolean, attribute: false }) checked = false;
+ @property({ attribute: false }) inputId?: string;
+ @property({ attribute: false }) inputType = "number";
+ @property({ attribute: false }) inputMin?: number | string;
+ @property({ attribute: false }) inputMax?: number | string;
+ @property({ attribute: false }) inputStep?: number | string;
+ @property({ attribute: false }) inputValue?: number | string;
+ @property({ attribute: false }) inputAriaLabel?: string;
+ @property({ attribute: false }) inputPlaceholder?: string;
+ @property({ attribute: false }) defaultInputValue?: number | string;
+ @property({ attribute: false }) minValidOnEnable?: number;
+ @property({ attribute: false }) onToggle?: (
+ checked: boolean,
+ value: number | string | undefined,
+ ) => void;
+ @property({ attribute: false }) onInput?: (e: Event) => void;
+ @property({ attribute: false }) onChange?: (e: Event) => void;
+ @property({ attribute: false }) onKeyDown?: (e: KeyboardEvent) => void;
+
+ createRenderRoot() {
+ return this;
+ }
+
+ protected updated(changedProperties: PropertyValues) {
+ if (!changedProperties.has("checked")) return;
+ const previousChecked = changedProperties.get("checked");
+ if (previousChecked === false && this.checked) {
+ const input = this.querySelector("input");
+ if (input) {
+ input.focus();
+ input.select();
+ }
+ }
+ }
+
+ private toOptionalNumber(
+ value: number | string | undefined,
+ ): number | undefined {
+ if (typeof value === "number") {
+ return Number.isFinite(value) ? value : undefined;
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim();
+ if (!trimmed) return undefined;
+ const numeric = Number(trimmed);
+ return Number.isFinite(numeric) ? numeric : undefined;
+ }
+ return undefined;
+ }
+
+ private resolveValueOnEnable(): number | string | undefined {
+ const currentValue = this.inputValue;
+
+ if (
+ currentValue === undefined ||
+ currentValue === null ||
+ currentValue === ""
+ ) {
+ return this.defaultInputValue;
+ }
+
+ if (this.minValidOnEnable === undefined) {
+ return currentValue;
+ }
+
+ const numericValue = this.toOptionalNumber(currentValue);
+ if (numericValue === undefined || numericValue < this.minValidOnEnable) {
+ return this.defaultInputValue;
+ }
+
+ return numericValue;
+ }
+
+ private emitToggle() {
+ const nextChecked = !this.checked;
+ const nextValue = nextChecked ? this.resolveValueOnEnable() : undefined;
+ this.onToggle?.(nextChecked, nextValue);
+ }
+
+ private handleCardClick = () => {
+ this.emitToggle();
+ };
+
+ render() {
+ return html`
+
+
+
+ ${this.checked
+ ? html`
+
+
+
+ `
+ : nothing}
+
+ `;
+ }
+}
diff --git a/src/client/components/map/MapDisplay.ts b/src/client/components/map/MapDisplay.ts
index d843a9999..61241eff2 100644
--- a/src/client/components/map/MapDisplay.ts
+++ b/src/client/components/map/MapDisplay.ts
@@ -53,6 +53,10 @@ export class MapDisplay extends LitElement {
}
}
+ private preventImageDrag(event: DragEvent) {
+ event.preventDefault();
+ }
+
render() {
return html`
${this.isLoading
? html`
{
return this.mapWins?.get(mapValue) ?? new Set();
}
@@ -54,7 +58,7 @@ export class MapPicker extends LitElement {
return html`
this.handleMapSelection(mapValue)}
- class="cursor-pointer transition-transform duration-200 active:scale-95"
+ class="cursor-pointer"
>