From 21d807dcee458ce3abde5cfbc345d44d126f0efc Mon Sep 17 00:00:00 2001 From: AlexBesios Date: Tue, 31 Mar 2026 00:51:10 +0300 Subject: [PATCH] feat: add NewsBox component and integrate news items into PlayPage --- resources/news.json | 23 ++++ src/client/components/NewsBox.ts | 170 ++++++++++++++++++++++++ src/client/components/PlayPage.ts | 4 + tests/client/components/NewsBox.test.ts | 94 +++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 resources/news.json create mode 100644 src/client/components/NewsBox.ts create mode 100644 tests/client/components/NewsBox.test.ts diff --git a/resources/news.json b/resources/news.json new file mode 100644 index 000000000..15a568685 --- /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/components/NewsBox.ts b/src/client/components/NewsBox.ts new file mode 100644 index 000000000..987f00a94 --- /dev/null +++ b/src/client/components/NewsBox.ts @@ -0,0 +1,170 @@ +import { LitElement, html, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import newsItems from "resources/news.json" with { type: "json" }; + +export interface NewsItem { + id: string; + title: string; + description: string; + url?: string; + type: "tournament" | "tutorial" | "announcement"; +} + +const DISMISSED_NEWS_KEY = "dismissedNewsItems"; +const CYCLE_INTERVAL_MS = 5000; + +function getDismissedIds(): Set { + try { + const raw = localStorage.getItem(DISMISSED_NEWS_KEY); + if (raw) return new Set(JSON.parse(raw)); + } catch { + // Ignore parse errors + } + return new Set(); +} + +function saveDismissedIds(ids: Set): void { + localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify([...ids])); +} + +export function getVisibleNewsItems(): NewsItem[] { + const dismissed = getDismissedIds(); + return (newsItems as NewsItem[]).filter((item) => !dismissed.has(item.id)); +} + +const typeLabels: Record = { + tournament: "TOURNAMENT", + tutorial: "TUTORIAL", + announcement: "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.items = getVisibleNewsItems(); + this.startCycle(); + } + + 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` +
+
+ ${typeLabels[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 9417b5fd2..35fa3f90e 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.js"; @customElement("play-page") export class PlayPage extends LitElement { @@ -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" > + + +
= {}; + 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(); + expect(items.length).toBe(newsItems.length); + }); + + it("filters out dismissed items", () => { + const items = getVisibleNewsItems(); + const firstId = items[0].id; + localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify([firstId])); + const filtered = getVisibleNewsItems(); + expect(filtered.find((i) => i.id === firstId)).toBeUndefined(); + expect(filtered.length).toBe(items.length - 1); + }); + + it("handles corrupted localStorage gracefully", () => { + localStorage.setItem(DISMISSED_NEWS_KEY, "not-valid-json"); + expect(() => getVisibleNewsItems()).not.toThrow(); + const items = getVisibleNewsItems(); + expect(items.length).toBe(newsItems.length); + }); + + it("returns empty when all items are dismissed", () => { + const allIds = (newsItems as NewsItem[]).map((i) => i.id); + localStorage.setItem(DISMISSED_NEWS_KEY, JSON.stringify(allIds)); + const items = getVisibleNewsItems(); + expect(items.length).toBe(0); + }); + }); + + describe("news items structure", () => { + it("each item has required fields", () => { + const items = getVisibleNewsItems(); + 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(); + const ids = items.map((i) => i.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("contains a tournament entry", () => { + const items = getVisibleNewsItems(); + expect(items.some((i) => i.type === "tournament")).toBe(true); + }); + }); +});