feat: add NewsBox component and integrate news items into PlayPage (#3545)

Resolves #2998 

## Description:

Adds a news box to the lobby homepage that advertises upcoming clan
tournaments, weekly tournaments, and new player tutorials. The component
sits above the username input and cycles through items automatically.

<img width="1138" height="591" alt="screenshot-2026-03-31_00-48-33"
src="https://github.com/user-attachments/assets/4b79287d-6aca-4c81-9bfe-36aad043f381"
/>

<img width="1107" height="595" alt="screenshot-2026-03-31_00-48-24"
src="https://github.com/user-attachments/assets/598e6b8b-e0f2-4864-a5fb-a00c0cc98f37"
/>

<img width="1367" height="599" alt="screenshot-2026-03-31_00-48-04"
src="https://github.com/user-attachments/assets/14f74e70-9dc0-4d67-af6e-c4708e539490"
/>


## 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:

deathllotus

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
Alex Besios
2026-04-08 20:53:02 +03:00
committed by GitHub
parent 38222bfe34
commit 55e8a4edb7
7 changed files with 337 additions and 1 deletions
+7
View File
@@ -1044,5 +1044,12 @@
"description": "(ALPHA)",
"login_required": "Login to play ranked!",
"must_login": "You must be logged in to play ranked matchmaking."
},
"news_box": {
"tournament": "TOURNAMENT",
"tutorial": "TUTORIAL",
"news": "NEWS",
"dismiss": "Dismiss",
"go_to_item": "Go to item {num}"
}
}
+23
View File
@@ -0,0 +1,23 @@
[
{
"id": "clan-tournament-spring-2026",
"title": "Upcoming: Spring Clan Tournament",
"description": "2v2 clan battles — Sign up on Discord before April 12",
"url": "https://discord.gg/openfront",
"type": "tournament"
},
{
"id": "clan-tournaments-2026",
"title": "Clan Tournaments",
"description": "Join a clan and compete in weekly tournaments on Discord!",
"url": "https://discord.gg/openfront",
"type": "tournament"
},
{
"id": "tutorial-2026",
"title": "New Player Tutorial",
"description": "Learn the basics of OpenFront in this video guide",
"url": "https://www.youtube.com/watch?v=EN2oOog3pSs",
"type": "tutorial"
}
]
+25
View File
@@ -1,7 +1,10 @@
import newsItemsFallback from "resources/news.json";
import { z } from "zod";
import type { NewsItem } from "../core/ApiSchemas";
import {
ClanLeaderboardResponse,
ClanLeaderboardResponseSchema,
NewsItemSchema,
PlayerProfile,
PlayerProfileSchema,
RankedLeaderboardResponse,
@@ -263,3 +266,25 @@ export async function fetchPlayerLeaderboard(
return false;
}
}
export async function getNews(): Promise<NewsItem[]> {
try {
const res = await fetch(`${getApiBase()}/news.json`, {
headers: { Accept: "application/json" },
});
if (res.status !== 200) {
console.warn("getNews: unexpected status", res.status);
return newsItemsFallback as NewsItem[];
}
const json = await res.json();
const parsed = z.array(NewsItemSchema).safeParse(json);
if (!parsed.success) {
console.warn("getNews: Zod validation failed", parsed.error);
return newsItemsFallback as NewsItem[];
}
return parsed.data;
} catch (err) {
console.warn("getNews: request failed, using fallback", err);
return newsItemsFallback as NewsItem[];
}
}
+180
View File
@@ -0,0 +1,180 @@
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import type { NewsItem } from "../../core/ApiSchemas";
import { getNews } from "../Api";
import { translateText } from "../Utils";
export type { NewsItem };
const DISMISSED_NEWS_KEY = "dismissedNewsItems";
const CYCLE_INTERVAL_MS = 5000;
function getDismissedIds(): Set<string> {
const raw = localStorage.getItem(DISMISSED_NEWS_KEY);
if (raw) return new Set(JSON.parse(raw));
return new Set();
}
function saveDismissedIds(ids: Set<string>): void {
localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify([...ids]));
}
export function getVisibleNewsItems(items: NewsItem[]): NewsItem[] {
const dismissed = getDismissedIds();
return items.filter((item) => !dismissed.has(item.id));
}
const typeLabelKeys: Record<NewsItem["type"], string> = {
tournament: "news_box.tournament",
tutorial: "news_box.tutorial",
announcement: "news_box.news",
};
const typeLabelColors: Record<NewsItem["type"], string> = {
tournament: "bg-amber-500/20 text-amber-300",
tutorial: "bg-sky-500/20 text-sky-300",
announcement: "bg-emerald-500/20 text-emerald-300",
};
@customElement("news-box")
export class NewsBox extends LitElement {
@state() private items: NewsItem[] = [];
@state() private activeIndex = 0;
private cycleTimer: ReturnType<typeof setInterval> | null = null;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
this.loadNews();
}
private async loadNews() {
try {
const allItems = await getNews();
// Reset stale dismissed list when all items would be hidden
const visible = getVisibleNewsItems(allItems);
if (visible.length === 0 && allItems.length > 0) {
localStorage.removeItem(DISMISSED_NEWS_KEY);
this.items = allItems;
} else {
this.items = visible;
}
this.startCycle();
} catch (e) {
console.error(e);
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.stopCycle();
}
private startCycle() {
this.stopCycle();
if (this.items.length > 1) {
this.cycleTimer = setInterval(() => {
this.activeIndex = (this.activeIndex + 1) % this.items.length;
}, CYCLE_INTERVAL_MS);
}
}
private stopCycle() {
if (this.cycleTimer !== null) {
clearInterval(this.cycleTimer);
this.cycleTimer = null;
}
}
private dismiss(id: string) {
const dismissed = getDismissedIds();
dismissed.add(id);
saveDismissedIds(dismissed);
this.items = this.items.filter((item) => item.id !== id);
if (this.activeIndex >= this.items.length) {
this.activeIndex = 0;
}
this.startCycle();
}
private goTo(index: number) {
this.activeIndex = index;
this.startCycle();
}
render() {
if (this.items.length === 0) return nothing;
const item = this.items[this.activeIndex];
return html`
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 lg:border-y-0 lg:rounded-xl lg:p-3"
>
<div class="flex items-center gap-3">
<span
class="shrink-0 text-[10px] font-bold tracking-wider px-2 py-0.5 rounded ${typeLabelColors[
item.type
]}"
>${translateText(typeLabelKeys[item.type])}</span
>
<div class="flex-1 min-w-0">
${item.url
? html`<a
href="${item.url}"
target="_blank"
rel="noopener noreferrer"
class="text-sm font-medium text-white hover:text-blue-300 transition-colors truncate block"
>${item.title}</a
>`
: html`<span class="text-sm font-medium text-white truncate block"
>${item.title}</span
>`}
<span class="text-xs text-white/50 truncate block"
>${item.description}</span
>
</div>
${this.items.length > 1
? html`
<div class="flex gap-1 shrink-0">
${this.items.map(
(_, i) => html`
<button
@click=${() => this.goTo(i)}
class="w-1.5 h-1.5 rounded-full transition-colors ${i ===
this.activeIndex
? "bg-white/60"
: "bg-white/20 hover:bg-white/40"}"
aria-label="${translateText("news_box.go_to_item", {
num: i + 1,
})}"
></button>
`,
)}
</div>
`
: nothing}
<button
@click=${() => this.dismiss(item.id)}
class="shrink-0 p-0.5 text-white/30 hover:text-white/70 transition-colors"
aria-label="${translateText("news_box.dismiss")}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3.5 h-3.5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
</div>
`;
}
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import "./NewsBox";
@customElement("play-page")
export class PlayPage extends LitElement {
@@ -11,7 +12,7 @@ export class PlayPage extends LitElement {
return html`
<div
id="page-play"
class="flex flex-col gap-2 w-full px-0 lg:px-4 lg:my-auto min-h-0"
class="flex flex-col gap-2 w-full px-0 lg:px-4 min-h-0"
>
<token-login class="absolute"></token-login>
@@ -107,6 +108,9 @@ export class PlayPage extends LitElement {
class="lg:hidden h-[calc(env(safe-area-inset-top)+56px)] lg:col-span-2 -mb-4"
></div>
<!-- News box above username -->
<news-box class="lg:col-span-2"></news-box>
<!-- Username: left col -->
<div
class="px-2 py-2 bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)] border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
+9
View File
@@ -191,3 +191,12 @@ export const RankedLeaderboardResponseSchema = z.object({
export type RankedLeaderboardResponse = z.infer<
typeof RankedLeaderboardResponseSchema
>;
export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string(),
url: z.string().nullable().optional(),
type: z.enum(["tournament", "tutorial", "announcement"]).or(z.string()),
});
export type NewsItem = z.infer<typeof NewsItemSchema>;
+88
View File
@@ -0,0 +1,88 @@
import newsItems from "../../../resources/news.json";
import {
getVisibleNewsItems,
NewsItem,
} from "../../../src/client/components/NewsBox";
const DISMISSED_NEWS_KEY = "dismissedNewsItems";
const allItems = newsItems as NewsItem[];
function createMockLocalStorage(): Storage {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => {
store[key] = String(value);
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
get length() {
return Object.keys(store).length;
},
key: (index: number) => Object.keys(store)[index] ?? null,
};
}
describe("NewsBox", () => {
beforeEach(() => {
vi.stubGlobal("localStorage", createMockLocalStorage());
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("getVisibleNewsItems", () => {
it("returns all items when none are dismissed", () => {
const items = getVisibleNewsItems(allItems);
expect(items.length).toBe(newsItems.length);
});
it("filters out dismissed items", () => {
const items = getVisibleNewsItems(allItems);
const firstId = items[0].id;
localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify([firstId]));
const filtered = getVisibleNewsItems(allItems);
expect(filtered.find((i) => i.id === firstId)).toBeUndefined();
expect(filtered.length).toBe(items.length - 1);
});
it("returns empty when all items are dismissed", () => {
const allIds = allItems.map((i) => i.id);
localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify(allIds));
const items = getVisibleNewsItems(allItems);
expect(items.length).toBe(0);
});
});
describe("news items structure", () => {
it("each item has required fields", () => {
const items = getVisibleNewsItems(allItems);
for (const item of items) {
expect(item.id).toBeDefined();
expect(typeof item.id).toBe("string");
expect(item.title).toBeDefined();
expect(typeof item.title).toBe("string");
expect(item.description).toBeDefined();
expect(typeof item.description).toBe("string");
expect(item.type).toBeDefined();
expect(["tournament", "tutorial", "announcement"]).toContain(item.type);
}
});
it("each item has a unique id", () => {
const items = getVisibleNewsItems(allItems);
const ids = items.map((i) => i.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("contains a tournament entry", () => {
const items = getVisibleNewsItems(allItems);
expect(items.some((i) => i.type === "tournament")).toBe(true);
});
});
});