mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user