mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 15:20:43 +00:00
Notification dot for new versions (+ mobile dot improvements) ✨ (#3265)
## Description: - **News notification dot (desktop + mobile)**: Added a red pinging dot on the "News" nav entry that appears when a new version is released. The current app version is saved to localStorage (`newsSeenVersion`) on first visit. On subsequent visits, if the version has changed, the dot appears. Clicking "News" dismisses it by updating the stored version. - **Mobile Store**: Replaced the static "NEW" text badge on the Store nav item with a red pinging dot (matching the desktop navbar style). The dot is conditionally shown based on cosmetics hash changes tracked in localStorage, and dismissed when the user clicks Store. - **Help dot on mobile**: Added the yellow help dot (already present on desktop) to the mobile navbar for consistency, shown for users with fewer than 10 games played. ### Screenshots: <img width="1028" height="97" alt="Screenshot 2026-02-21 174029" src="https://github.com/user-attachments/assets/1ed460dd-4e41-4287-bcb9-73f431e8a953" /> <img width="513" height="700" alt="Screenshot 2026-02-21 174333" src="https://github.com/user-attachments/assets/c6b81296-d36b-424e-9637-e738acd8007a" /> ## 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 - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin
This commit is contained in:
@@ -42,7 +42,6 @@
|
||||
"play": "Play",
|
||||
"news": "News",
|
||||
"store": "Store",
|
||||
"store_new_badge": "NEW",
|
||||
"settings": "Settings",
|
||||
"leaderboard": "Leaderboard",
|
||||
"account": "Account",
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { getCosmeticsHash } from "../Cosmetics";
|
||||
import { getGamesPlayed } from "../Utils";
|
||||
|
||||
const HELP_SEEN_KEY = "helpSeen";
|
||||
const STORE_SEEN_HASH_KEY = "storeSeenHash";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { NavNotificationsController } from "./NavNotificationsController";
|
||||
|
||||
@customElement("desktop-nav-bar")
|
||||
export class DesktopNavBar extends LitElement {
|
||||
@state() private _helpSeen = localStorage.getItem(HELP_SEEN_KEY) === "true";
|
||||
@state() private _hasNewCosmetics = false;
|
||||
private _notifications = new NavNotificationsController(this);
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -26,12 +21,6 @@ export class DesktopNavBar extends LitElement {
|
||||
this._updateActiveState(current);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if cosmetics have changed
|
||||
getCosmeticsHash().then((hash: string | null) => {
|
||||
const seenHash = localStorage.getItem(STORE_SEEN_HASH_KEY);
|
||||
this._hasNewCosmetics = hash !== null && hash !== seenHash;
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -54,30 +43,6 @@ export class DesktopNavBar extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private showHelpDot(): boolean {
|
||||
// Only show one dot at a time to prevent
|
||||
// overwhelming users.
|
||||
return getGamesPlayed() < 10 && !this._helpSeen;
|
||||
}
|
||||
|
||||
private showStoreDot(): boolean {
|
||||
return this._hasNewCosmetics && !this.showHelpDot();
|
||||
}
|
||||
|
||||
private onHelpClick = () => {
|
||||
localStorage.setItem(HELP_SEEN_KEY, "true");
|
||||
this._helpSeen = true;
|
||||
};
|
||||
|
||||
private onStoreClick = () => {
|
||||
this._hasNewCosmetics = false;
|
||||
getCosmeticsHash().then((hash: string | null) => {
|
||||
if (hash !== null) {
|
||||
localStorage.setItem(STORE_SEEN_HASH_KEY, hash);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
window.currentPageId ??= "page-play";
|
||||
const currentPage = window.currentPageId;
|
||||
@@ -142,13 +107,26 @@ export class DesktopNavBar extends LitElement {
|
||||
data-i18n="main.play"
|
||||
></button>
|
||||
<!-- Desktop Navigation Menu Items -->
|
||||
<button
|
||||
class="nav-menu-item ${currentPage === "page-news"
|
||||
? "active"
|
||||
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-news"
|
||||
data-i18n="main.news"
|
||||
></button>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="nav-menu-item ${currentPage === "page-news"
|
||||
? "active"
|
||||
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-news"
|
||||
data-i18n="main.news"
|
||||
@click=${this._notifications.onNewsClick}
|
||||
></button>
|
||||
${this._notifications.showNewsDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-ping"
|
||||
></span>
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"
|
||||
></span>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="relative no-crazygames">
|
||||
<button
|
||||
class="nav-menu-item ${currentPage === "page-item-store"
|
||||
@@ -156,9 +134,9 @@ export class DesktopNavBar extends LitElement {
|
||||
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-item-store"
|
||||
data-i18n="main.store"
|
||||
@click=${this.onStoreClick}
|
||||
@click=${this._notifications.onStoreClick}
|
||||
></button>
|
||||
${this.showStoreDot()
|
||||
${this._notifications.showStoreDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-ping"
|
||||
@@ -184,9 +162,9 @@ export class DesktopNavBar extends LitElement {
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-help"
|
||||
data-i18n="main.help"
|
||||
@click=${this.onHelpClick}
|
||||
@click=${this._notifications.onHelpClick}
|
||||
></button>
|
||||
${this.showHelpDot()
|
||||
${this._notifications.showHelpDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full animate-ping"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { NavNotificationsController } from "./NavNotificationsController";
|
||||
|
||||
@customElement("mobile-nav-bar")
|
||||
export class MobileNavBar extends LitElement {
|
||||
private _notifications = new NavNotificationsController(this);
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -31,14 +34,24 @@ export class MobileNavBar extends LitElement {
|
||||
|
||||
private _updateActiveState(pageId: string) {
|
||||
this.querySelectorAll(".nav-menu-item").forEach((el) => {
|
||||
const inner = el.querySelector("button");
|
||||
if ((el as HTMLElement).dataset.page === pageId) {
|
||||
el.classList.add("active");
|
||||
inner?.classList.add("active");
|
||||
} else {
|
||||
el.classList.remove("active");
|
||||
inner?.classList.remove("active");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _renderDot(color: string): TemplateResult {
|
||||
return html`<span class="relative ml-2 shrink-0 -mt-2 w-2 h-2">
|
||||
<span class="absolute inset-0 ${color} rounded-full animate-ping"></span>
|
||||
<span class="absolute inset-0 ${color} rounded-full"></span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
window.currentPageId ??= "page-play";
|
||||
const currentPage = window.currentPageId;
|
||||
@@ -120,26 +133,36 @@ export class MobileNavBar extends LitElement {
|
||||
data-page="page-play"
|
||||
data-i18n="main.play"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
<div
|
||||
class="nav-menu-item flex items-center w-full cursor-pointer"
|
||||
data-page="page-news"
|
||||
data-i18n="main.news"
|
||||
></button>
|
||||
@click=${this._notifications.onNewsClick}
|
||||
>
|
||||
<button
|
||||
class="block text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-i18n="main.news"
|
||||
></button>
|
||||
${this._notifications.showNewsDot()
|
||||
? this._renderDot("bg-red-500")
|
||||
: ""}
|
||||
</div>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-page="page-leaderboard"
|
||||
data-i18n="main.leaderboard"
|
||||
></button>
|
||||
<div class="relative no-crazygames">
|
||||
<div
|
||||
class="no-crazygames nav-menu-item flex items-center w-full cursor-pointer"
|
||||
data-page="page-item-store"
|
||||
@click=${this._notifications.onStoreClick}
|
||||
>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-page="page-item-store"
|
||||
class="block text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-i18n="main.store"
|
||||
></button>
|
||||
<span
|
||||
class="absolute top-[45%] -translate-y-1/2 right-4 bg-gradient-to-br from-red-600 to-red-700 text-white text-[10px] font-black tracking-wider px-2.5 py-1 rounded rotate-12 shadow-lg shadow-red-600/50 animate-pulse pointer-events-none"
|
||||
data-i18n="main.store_new_badge"
|
||||
></span>
|
||||
${this._notifications.showStoreDot()
|
||||
? this._renderDot("bg-red-500")
|
||||
: ""}
|
||||
</div>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
@@ -151,11 +174,19 @@ export class MobileNavBar extends LitElement {
|
||||
data-page="page-account"
|
||||
data-i18n="main.account"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
<div
|
||||
class="nav-menu-item flex items-center w-full cursor-pointer"
|
||||
data-page="page-help"
|
||||
data-i18n="main.help"
|
||||
></button>
|
||||
@click=${this._notifications.onHelpClick}
|
||||
>
|
||||
<button
|
||||
class="block text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-i18n="main.help"
|
||||
></button>
|
||||
${this._notifications.showHelpDot()
|
||||
? this._renderDot("bg-yellow-400")
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col w-full mt-auto [.in-game_&]:hidden items-end justify-end pt-4 border-t border-white/10"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import version from "resources/version.txt?raw";
|
||||
import { getCosmeticsHash } from "../Cosmetics";
|
||||
import { getGamesPlayed } from "../Utils";
|
||||
|
||||
const HELP_SEEN_KEY = "helpSeen";
|
||||
const STORE_SEEN_HASH_KEY = "storeSeenHash";
|
||||
const NEWS_SEEN_VERSION_KEY = "newsSeenVersion";
|
||||
|
||||
export class NavNotificationsController implements ReactiveController {
|
||||
private host: ReactiveControllerHost;
|
||||
|
||||
private _helpSeen = localStorage.getItem(HELP_SEEN_KEY) === "true";
|
||||
private _hasNewCosmetics = false;
|
||||
private _hasNewVersion = false;
|
||||
|
||||
private get normalizedVersion(): string {
|
||||
const trimmed = version.trim();
|
||||
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
|
||||
}
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this.host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
// Check if cosmetics have changed
|
||||
getCosmeticsHash()
|
||||
.then((hash: string | null) => {
|
||||
const seenHash = localStorage.getItem(STORE_SEEN_HASH_KEY);
|
||||
this._hasNewCosmetics = hash !== null && hash !== seenHash;
|
||||
this.host.requestUpdate();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Check if version has changed
|
||||
const currentVersion = this.normalizedVersion;
|
||||
const seenVersion = localStorage.getItem(NEWS_SEEN_VERSION_KEY);
|
||||
this._hasNewVersion =
|
||||
seenVersion !== null && seenVersion !== currentVersion;
|
||||
if (seenVersion === null) {
|
||||
localStorage.setItem(NEWS_SEEN_VERSION_KEY, currentVersion);
|
||||
}
|
||||
}
|
||||
|
||||
hostDisconnected(): void {}
|
||||
|
||||
// Only show one dot at a time to prevent
|
||||
// overwhelming users. Priority: News > Store > Help.
|
||||
showNewsDot(): boolean {
|
||||
return this._hasNewVersion;
|
||||
}
|
||||
|
||||
showStoreDot(): boolean {
|
||||
return this._hasNewCosmetics && !this.showNewsDot();
|
||||
}
|
||||
|
||||
showHelpDot(): boolean {
|
||||
return (
|
||||
getGamesPlayed() < 10 &&
|
||||
!this._helpSeen &&
|
||||
!this.showNewsDot() &&
|
||||
!this.showStoreDot()
|
||||
);
|
||||
}
|
||||
|
||||
onNewsClick = (): void => {
|
||||
this._hasNewVersion = false;
|
||||
localStorage.setItem(NEWS_SEEN_VERSION_KEY, this.normalizedVersion);
|
||||
this.host.requestUpdate();
|
||||
};
|
||||
|
||||
onStoreClick = (): void => {
|
||||
this._hasNewCosmetics = false;
|
||||
getCosmeticsHash()
|
||||
.then((hash: string | null) => {
|
||||
if (hash !== null) {
|
||||
localStorage.setItem(STORE_SEEN_HASH_KEY, hash);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
this.host.requestUpdate();
|
||||
};
|
||||
|
||||
onHelpClick = (): void => {
|
||||
localStorage.setItem(HELP_SEEN_KEY, "true");
|
||||
this._helpSeen = true;
|
||||
this.host.requestUpdate();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user