diff --git a/resources/lang/en.json b/resources/lang/en.json index a1dace8393..d624362570 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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}" } } diff --git a/resources/news.json b/resources/news.json new file mode 100644 index 0000000000..15a5686859 --- /dev/null +++ b/resources/news.json @@ -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" + } +] diff --git a/src/client/Api.ts b/src/client/Api.ts index c28f59b204..347ba18373 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -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 { + 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[]; + } +} diff --git a/src/client/components/NewsBox.ts b/src/client/components/NewsBox.ts new file mode 100644 index 0000000000..d43b8e59b1 --- /dev/null +++ b/src/client/components/NewsBox.ts @@ -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 { + const raw = localStorage.getItem(DISMISSED_NEWS_KEY); + if (raw) return new Set(JSON.parse(raw)); + return new Set(); +} + +function saveDismissedIds(ids: Set): 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 = { + tournament: "news_box.tournament", + tutorial: "news_box.tutorial", + announcement: "news_box.news", +}; + +const typeLabelColors: Record = { + 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 | 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` +
+
+ ${translateText(typeLabelKeys[item.type])} +
+ ${item.url + ? html`${item.title}` + : html`${item.title}`} + ${item.description} +
+ ${this.items.length > 1 + ? html` +
+ ${this.items.map( + (_, i) => html` + + `, + )} +
+ ` + : nothing} + +
+
+ `; + } +} diff --git a/src/client/components/PlayPage.ts b/src/client/components/PlayPage.ts index 9417b5fd2e..00db01b277 100644 --- a/src/client/components/PlayPage.ts +++ b/src/client/components/PlayPage.ts @@ -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`
@@ -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" >
+ + +
; + +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; diff --git a/tests/client/components/NewsBox.test.ts b/tests/client/components/NewsBox.test.ts new file mode 100644 index 0000000000..6767beb7d9 --- /dev/null +++ b/tests/client/components/NewsBox.test.ts @@ -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 = {}; + 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); + }); + }); +});