-
Notifications
You must be signed in to change notification settings - Fork 950
feat: add NewsBox component and integrate news items into PlayPage #3545
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
21d807d
b58319b
dbaa613
7bc16f2
d4138a1
cfd9946
9eb7861
1f1937f
7b270c6
613132f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> | ||
| `; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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()), | ||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Verify how NewsItem.type is consumed and whether exhaustive handling is expected.
rg -n -C2 --type ts --type tsx 'NewsItem\b|NewsItemSchema\b|\.type\b|switch\s*\(.*type'
# Look for branches that assume only known literals.
rg -n -C2 --type ts --type tsx '===\s*["'"'"'](tournament|tutorial|announcement)["'"'"']|case\s+["'"'"'](tournament|tutorial|announcement)["'"'"']'Repository: openfrontio/OpenFrontIO Length of output: 94 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Search for NewsItem and NewsItemSchema usage with corrected syntax
rg -n -C2 'NewsItem\b|NewsItemSchema\b' --type ts
# Look for specific type value checks (tournament, tutorial, announcement)
echo "=== Type value checks ==="
rg -n -C2 '(tournament|tutorial|announcement)' --type ts | head -50Repository: openfrontio/OpenFrontIO Length of output: 7859 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Check the exact definition of NewsItem["type"] and how Record<> uses it
cat -n src/client/components/NewsBox.ts | sed -n '35,45p'
# Look for any type errors in the NewsBox file
cat -n src/core/ApiSchemas.ts | sed -n '195,202p'
# Check if TypeScript would allow this pattern with z.string().or()
echo "=== Checking how Zod infers the type ==="
grep -A5 'export type NewsItem' src/core/ApiSchemas.tsRepository: openfrontio/OpenFrontIO Length of output: 949 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Check if there are runtime safety checks when accessing the type
cat -n src/client/components/NewsBox.ts | sed -n '50,100p'
# Check for any optional chaining or safety checks on typeLabelKeys/typeLabelColors access
rg -n 'typeLabelKeys\[|typeLabelColors\[|typeLabelKeys\.get|typeLabelColors\.get' src/client/components/NewsBox.ts -A2Repository: openfrontio/OpenFrontIO Length of output: 1948 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Check if TypeScript would allow Record<string, T> to be assigned 3 properties
# by examining the actual behavior. Let's also check the news.json fallback to see
# what types are actually used in practice
cat -n resources/news.json | head -30
# Check if there are any error handling patterns for unknown types
rg -n 'typeLabelKeys|typeLabelColors' src/client/components/NewsBox.ts -B1 -A3Repository: openfrontio/OpenFrontIO Length of output: 1654 Fix the The current schema Since all actual data only uses the three known types, split the schema into a strict enum for validation and add a type guard for safe narrowing: ♻️ Suggested refactor+export const KnownNewsTypeSchema = z.enum(["tournament", "tutorial", "announcement"]);
+export type KnownNewsType = z.infer<typeof KnownNewsTypeSchema>;
+
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()),
+ type: KnownNewsTypeSchema,
});
export type NewsItem = z.infer<typeof NewsItemSchema>;
+
+export const isKnownNewsType = (value: string): value is KnownNewsType =>
+ KnownNewsTypeSchema.safeParse(value).success;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||
| export type NewsItem = z.infer<typeof NewsItemSchema>; | ||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.