mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-23 02:35:40 +00:00
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:
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user