Feat: Favourite maps tab (#4207)

Resolves #4202 

## Description:

As suggested in some suggestions in the main OF server
[[thread](https://discord.com/channels/1284581928254701718/1472496670267805782)],
we should have a map favouriting system since there are over 70 maps
already. People (myself included) have some maps we constantly play
during solo/private matches, so a favourite tab would be huge.

This feature adds the favourites tab to the solo and private match
selection screens. It works using localStorage for saving (device
persistence) but I can just as easily implement an infra update where
players have a 1-many relation with a `FavouriteMaps` table. That can be
a future solution. Video example right now:


https://github.com/user-attachments/assets/e8e278ab-d305-499a-81a9-d570e05db051


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

bijx
This commit is contained in:
bijx
2026-06-10 16:51:37 -04:00
committed by GitHub
parent 9e9708468c
commit fe0b79ef21
4 changed files with 161 additions and 29 deletions
+7 -2
View File
@@ -521,6 +521,7 @@
"map": "Map",
"featured": "Featured",
"all": "All",
"favorites": "Favourites",
"world": "World",
"worldinverted": "World Inverted",
"giantworldmap": "Giant World Map",
@@ -622,11 +623,15 @@
"tournament": "Tournament",
"fantasy": "Other",
"special": "Special",
"arcade": "Arcade"
"arcade": "Arcade",
"favorites": "Favourites"
},
"map_component": {
"loading": "Loading...",
"error": "Error"
"error": "Error",
"favorite": "Add to favourites",
"unfavorite": "Remove from favourites",
"favorites_empty": "Click the star on any map to favourite it"
},
"private_lobby": {
"title": "Join Private Lobby",
+32
View File
@@ -4,6 +4,7 @@ import medalIconRaw from "../../../../resources/images/MedalIconWhite.svg?raw";
import { Difficulty, GameMapType } from "../../../core/game/Game";
import { terrainMapFileLoader } from "../../TerrainMapFileLoader";
import { translateText } from "../../Utils";
import { starIcon } from "./MapFavorites";
const medalMaskUrl = `url('data:image/svg+xml;utf8,${encodeURIComponent(medalIconRaw)}') no-repeat center / contain`;
@@ -13,7 +14,9 @@ export class MapDisplay extends LitElement {
@property({ type: Boolean }) selected = false;
@property({ type: String }) translation: string = "";
@property({ type: Boolean }) showMedals = false;
@property({ type: Boolean }) favorite = false;
@property({ attribute: false }) wins: Set<Difficulty> = new Set();
@property({ attribute: false }) onToggleFavorite?: () => void;
@state() private mapWebpPath: string | null = null;
@state() private mapName: string | null = null;
@state() private isLoading = true;
@@ -78,6 +81,34 @@ export class MapDisplay extends LitElement {
event.preventDefault();
}
private handleToggleFavorite(event: Event) {
event.stopPropagation();
event.preventDefault();
this.onToggleFavorite?.();
}
private renderFavoriteButton() {
if (!this.onToggleFavorite) return null;
return html`<button
type="button"
@click=${this.handleToggleFavorite}
@keydown=${(e: KeyboardEvent) => e.stopPropagation()}
aria-pressed=${this.favorite}
aria-label=${translateText(
this.favorite ? "map_component.unfavorite" : "map_component.favorite",
)}
title=${translateText(
this.favorite ? "map_component.unfavorite" : "map_component.favorite",
)}
class="absolute top-1.5 right-1.5 w-7 h-7 flex items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-all duration-200 active:scale-90 ${this
.favorite
? "opacity-100 text-cyber-yellow"
: "opacity-0 group-hover:opacity-100 text-white hover:text-cyber-yellow"}"
>
${starIcon(this.favorite, "w-4 h-4")}
</button>`;
}
render() {
return html`
<div
@@ -110,6 +141,7 @@ export class MapDisplay extends LitElement {
? "opacity-100"
: "opacity-80"} group-hover:opacity-100 transition-opacity duration-200"
/>
${this.renderFavoriteButton()}
</div>`
: html`<div
class="w-full aspect-[2/1] text-red-400 transition-transform duration-200 rounded-lg bg-red-500/10 text-xs font-bold uppercase tracking-wider flex items-center justify-center"
+57
View File
@@ -0,0 +1,57 @@
import { TemplateResult, html } from "lit";
import { GameMapType } from "../../../core/game/Game";
const FAVORITES_KEY = "map-favorites";
const validMaps = new Set<string>(Object.values(GameMapType));
export function getFavoriteMaps(): GameMapType[] {
try {
const raw = localStorage.getItem(FAVORITES_KEY);
if (raw === null) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(m): m is GameMapType => typeof m === "string" && validMaps.has(m),
);
} catch (error) {
console.warn("Failed to read favorite maps from localStorage:", error);
return [];
}
}
export function isFavoriteMap(map: GameMapType): boolean {
return getFavoriteMaps().includes(map);
}
export function toggleFavoriteMap(map: GameMapType): GameMapType[] {
const favorites = getFavoriteMaps();
const index = favorites.indexOf(map);
if (index >= 0) {
favorites.splice(index, 1);
} else {
favorites.push(map);
}
try {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
} catch (error) {
console.warn("Failed to save favorite maps to localStorage:", error);
}
return favorites;
}
export function starIcon(filled: boolean, className = ""): TemplateResult {
return html`<svg
viewBox="0 0 24 24"
class=${className}
fill=${filled ? "currentColor" : "none"}
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
aria-hidden="true"
>
<path
d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"
/>
</svg>`;
}
+65 -27
View File
@@ -8,8 +8,11 @@ import {
} from "../../../core/game/Game";
import { translateText } from "../../Utils";
import "./MapDisplay";
import { getFavoriteMaps, starIcon, toggleFavoriteMap } from "./MapFavorites";
const randomMap = assetUrl("images/RandomMap.webp");
type MapTab = "featured" | "all" | "favorites";
const featuredMaps: GameMapType[] = [
GameMapType.World,
GameMapType.Europe,
@@ -30,12 +33,17 @@ export class MapPicker extends LitElement {
new Map();
@property({ attribute: false }) onSelectMap?: (map: GameMapType) => void;
@property({ attribute: false }) onSelectRandom?: () => void;
@state() private showAllMaps = false;
@state() private activeTab: MapTab = "featured";
@state() private favorites: GameMapType[] = getFavoriteMaps();
createRenderRoot() {
return this;
}
private handleToggleFavorite(mapValue: GameMapType) {
this.favorites = toggleFavoriteMap(mapValue);
}
private handleMapSelection(mapValue: GameMapType) {
this.onSelectMap?.(mapValue);
}
@@ -66,6 +74,8 @@ export class MapPicker extends LitElement {
.selected=${!this.useRandomMap && this.selectedMap === mapValue}
.showMedals=${this.showMedals}
.wins=${this.getWins(mapValue)}
.favorite=${this.favorites.includes(mapValue)}
.onToggleFavorite=${() => this.handleToggleFavorite(mapValue)}
.translation=${translateText(`map.${mapKey?.toLowerCase()}`)}
></map-display>
</div>
@@ -109,6 +119,55 @@ export class MapPicker extends LitElement {
</div>`;
}
private renderFavoriteMaps() {
if (this.favorites.length === 0) {
return html`<div
class="w-full flex flex-col items-center justify-center gap-3 py-12 px-4 text-center rounded-xl border border-dashed border-white/10 bg-black/20"
>
<div class="text-white/30">${starIcon(false, "w-8 h-8")}</div>
<p class="text-sm text-white/50 leading-relaxed max-w-xs">
${translateText("map_component.favorites_empty")}
</p>
</div>`;
}
return html`<div class="w-full">
<h4
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
>
${translateText("map_categories.favorites")}
</h4>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
${this.favorites.map((mapValue) => this.renderMapCard(mapValue))}
</div>
</div>`;
}
private renderActiveTab() {
switch (this.activeTab) {
case "all":
return this.renderAllMaps();
case "favorites":
return this.renderFavoriteMaps();
default:
return this.renderFeaturedMaps();
}
}
private renderTabButton(tab: MapTab, label: string) {
const isActive = this.activeTab === tab;
return html`<button
type="button"
role="tab"
aria-selected=${isActive}
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${isActive
? "bg-malibu-blue/20 text-white shadow-[var(--shadow-malibu-blue-soft)]"
: "text-white/60 hover:text-white"}"
@click=${() => (this.activeTab = tab)}
>
${label}
</button>`;
}
render() {
return html`
<div class="space-y-8">
@@ -116,35 +175,14 @@ export class MapPicker extends LitElement {
<div
role="tablist"
aria-label="${translateText("map.map")}"
class="grid grid-cols-2 gap-2 rounded-xl border border-white/10 bg-black/20 p-1"
class="grid grid-cols-3 gap-2 rounded-xl border border-white/10 bg-black/20 p-1"
>
<button
type="button"
role="tab"
aria-selected=${!this.showAllMaps}
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
.showAllMaps
? "text-white/60 hover:text-white"
: "bg-malibu-blue/20 text-white shadow-[var(--shadow-malibu-blue-soft)]"}"
@click=${() => (this.showAllMaps = false)}
>
${translateText("map.featured")}
</button>
<button
type="button"
role="tab"
aria-selected=${this.showAllMaps}
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all active:scale-95 ${this
.showAllMaps
? "bg-malibu-blue/20 text-white shadow-[var(--shadow-malibu-blue-soft)]"
: "text-white/60 hover:text-white"}"
@click=${() => (this.showAllMaps = true)}
>
${translateText("map.all")}
</button>
${this.renderTabButton("featured", translateText("map.featured"))}
${this.renderTabButton("all", translateText("map.all"))}
${this.renderTabButton("favorites", translateText("map.favorites"))}
</div>
</div>
${this.showAllMaps ? this.renderAllMaps() : this.renderFeaturedMaps()}
${this.renderActiveTab()}
<div
class="w-full ${this.randomMapDivider
? "pt-4 border-t border-white/5"