From 9a85363e44c52d6992c6fb09790fb45d50451bac Mon Sep 17 00:00:00 2001
From: sagit <36596628+Sagit-chu@users.noreply.github.com>
Date: Sat, 4 Apr 2026 13:00:11 +0800
Subject: [PATCH] feat: Announcement Popup Notification (#411)
* docs: add announcement popup design spec
* docs: add announcement popup implementation plan
* feat(api): include update_time in announcement response
* feat(ui): add update_time to AnnouncementData interface
* feat(ui): create AnnouncementModal component
* feat(ui): manage announcement modal state in dashboard hook
* feat(ui): add announcement modal to dashboard layout
---
.../2026-04-04-announcement-popup-plan.md | 262 ++++++++++++++++++
.../2026-04-04-announcement-popup-design.md | 36 +++
go-backend/internal/http/handler/handler.go | 15 +-
vite-frontend/src/api/index.ts | 1 +
vite-frontend/src/pages/dashboard.tsx | 12 +
.../components/announcement-modal.tsx | 48 ++++
.../src/pages/dashboard/use-dashboard-data.ts | 31 +++
7 files changed, 401 insertions(+), 4 deletions(-)
create mode 100644 docs/superpowers/plans/2026-04-04-announcement-popup-plan.md
create mode 100644 docs/superpowers/specs/2026-04-04-announcement-popup-design.md
create mode 100644 vite-frontend/src/pages/dashboard/components/announcement-modal.tsx
diff --git a/docs/superpowers/plans/2026-04-04-announcement-popup-plan.md b/docs/superpowers/plans/2026-04-04-announcement-popup-plan.md
new file mode 100644
index 0000000..1303b7e
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-04-announcement-popup-plan.md
@@ -0,0 +1,262 @@
+# Announcement Popup Notification Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a popup modal for announcements that automatically shows to users when a new or updated announcement is published.
+
+**Architecture:** We will modify the Go backend to return `update_time` along with the announcement data. In the Vite frontend, we will store the user's `flvx_announcement_seen_time` in `localStorage`. If the fetched `update_time` is greater than the stored timestamp, we trigger a NextUI Modal displaying the announcement content.
+
+**Tech Stack:** Go, Vite, React, TailwindCSS, NextUI.
+
+---
+
+### Task 1: Update API Response in Go Backend
+
+**Files:**
+- Modify: `go-backend/internal/http/handler/handler.go`
+
+- [ ] **Step 1: Write the minimal implementation**
+
+Modify the `getAnnouncement` function in `go-backend/internal/http/handler/handler.go`.
+Find the response map inside `getAnnouncement` and add the `update_time` key:
+
+```go
+ if ann == nil {
+ response.WriteJSON(w, response.OK(map[string]interface{}{
+ "content": "",
+ "enabled": 0,
+ "update_time": 0,
+ }))
+ return
+ }
+
+ updateTime := ann.CreatedTime
+ if ann.UpdatedTime.Valid {
+ updateTime = ann.UpdatedTime.Int64
+ }
+
+ response.WriteJSON(w, response.OK(map[string]interface{}{
+ "content": ann.Content,
+ "enabled": ann.Enabled,
+ "update_time": updateTime,
+ }))
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add go-backend/internal/http/handler/handler.go
+git commit -m "feat(api): include update_time in announcement response"
+```
+
+---
+
+### Task 2: Update Frontend API Interface
+
+**Files:**
+- Modify: `vite-frontend/src/api/index.ts`
+
+- [ ] **Step 1: Write the minimal implementation**
+
+Modify the `AnnouncementData` interface in `vite-frontend/src/api/index.ts` to include `update_time`.
+
+```typescript
+export interface AnnouncementData {
+ content: string;
+ enabled: number;
+ update_time?: number;
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add vite-frontend/src/api/index.ts
+git commit -m "feat(ui): add update_time to AnnouncementData interface"
+```
+
+---
+
+### Task 3: Create AnnouncementModal Component
+
+**Files:**
+- Create: `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx`
+
+- [ ] **Step 1: Write the minimal implementation**
+
+Create `vite-frontend/src/pages/dashboard/components/announcement-modal.tsx` with the following content:
+
+```tsx
+import type { AnnouncementData } from "@/api";
+import { Button } from "@/shadcn-bridge/heroui/button";
+import {
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+} from "@/shadcn-bridge/heroui/modal";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+interface AnnouncementModalProps {
+ announcement: AnnouncementData;
+ isOpen: boolean;
+ onClose: () => void;
+ onDontShowAgain: () => void;
+}
+
+export const AnnouncementModal = ({
+ announcement,
+ isOpen,
+ onClose,
+ onDontShowAgain,
+}: AnnouncementModalProps) => {
+ return (
+ !open && onClose()} size="2xl">
+
+ 平台公告
+
+
+
+ {announcement.content}
+
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add vite-frontend/src/pages/dashboard/components/announcement-modal.tsx
+git commit -m "feat(ui): create AnnouncementModal component"
+```
+
+---
+
+### Task 4: Integrate Modal State in Dashboard Custom Hook
+
+**Files:**
+- Modify: `vite-frontend/src/pages/dashboard/use-dashboard-data.ts`
+
+- [ ] **Step 1: Update the hook return type interface**
+
+At the top of `vite-frontend/src/pages/dashboard/use-dashboard-data.ts` where `DashboardData` is or similar, add the new properties (if it uses an explicit return type). If it's inferred, skip this. Wait, let's check the code:
+
+```typescript
+ isAnnouncementModalOpen: boolean;
+ setIsAnnouncementModalOpen: (isOpen: boolean) => void;
+ dismissAnnouncementModal: () => void;
+```
+Ensure they are added to the returned object at the bottom of the `useDashboardData` hook.
+
+Find the `const loadAnnouncement` function.
+
+- [ ] **Step 2: Write the minimal implementation**
+
+First, add state at the top of the hook:
+```typescript
+ const [isAnnouncementModalOpen, setIsAnnouncementModalOpen] = useState(false);
+```
+
+Then, modify the `loadAnnouncement` logic inside `useDashboardData`:
+```typescript
+ if (res.code === 0 && res.data && res.data.enabled === 1) {
+ setAnnouncement(res.data);
+
+ try {
+ const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time");
+ const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
+ const updateTime = res.data.update_time || 0;
+
+ if (updateTime > storedTime) {
+ setIsAnnouncementModalOpen(true);
+ }
+ } catch (err) {
+ console.warn("Failed to read localStorage for announcement state", err);
+ setIsAnnouncementModalOpen(true);
+ }
+ } else {
+ setAnnouncement(null);
+ }
+```
+
+Add the dismiss handler inside the hook:
+```typescript
+ const dismissAnnouncementModal = useCallback(() => {
+ setIsAnnouncementModalOpen(false);
+ if (announcement && announcement.update_time) {
+ try {
+ localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString());
+ } catch (err) {
+ console.warn("Failed to set localStorage for announcement state", err);
+ }
+ }
+ }, [announcement]);
+```
+
+Ensure these are included in the return object of the hook:
+```typescript
+ isAnnouncementModalOpen,
+ setIsAnnouncementModalOpen,
+ dismissAnnouncementModal,
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add vite-frontend/src/pages/dashboard/use-dashboard-data.ts
+git commit -m "feat(ui): manage announcement modal state in dashboard hook"
+```
+
+---
+
+### Task 5: Add Modal to Dashboard Layout
+
+**Files:**
+- Modify: `vite-frontend/src/pages/dashboard.tsx`
+
+- [ ] **Step 1: Write the minimal implementation**
+
+Import the modal component at the top:
+```tsx
+import { AnnouncementModal } from "@/pages/dashboard/components/announcement-modal";
+```
+
+Add the new properties to the destructured `useDashboardData` object:
+```tsx
+ isAnnouncementModalOpen,
+ setIsAnnouncementModalOpen,
+ dismissAnnouncementModal,
+```
+
+Add the modal instance near the end of the dashboard rendering (just below `{announcement && }` or inside the main `
`):
+```tsx
+ {announcement && (
+ setIsAnnouncementModalOpen(false)}
+ onDontShowAgain={dismissAnnouncementModal}
+ />
+ )}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add vite-frontend/src/pages/dashboard.tsx
+git commit -m "feat(ui): add announcement modal to dashboard layout"
+```
diff --git a/docs/superpowers/specs/2026-04-04-announcement-popup-design.md b/docs/superpowers/specs/2026-04-04-announcement-popup-design.md
new file mode 100644
index 0000000..817d26e
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-04-announcement-popup-design.md
@@ -0,0 +1,36 @@
+# Announcement Popup Notification Design
+
+## Overview
+This feature implements a popup notification modal for important dashboard announcements to ensure users see them immediately, addressing GitHub Issue #169.
+
+## Requirements
+1. Automatic display of a popup modal when opening the dashboard page if a new/updated announcement exists.
+2. Includes a "Don't show again" option to remember the user's choice to dismiss it.
+3. Smart triggering: Only pops up for *new* or *updated* announcements.
+4. Support Markdown formatting for the announcement content.
+5. Retain the existing permanent top banner as a fallback.
+
+## Backend Changes (Go)
+The `/api/v1/announcement/get` API currently only returns `content` and `enabled`. It must be updated to return the timestamp of the last update to enable the frontend to detect changes.
+
+1. **Repository (`internal/store/repo/repository.go`)**: Ensure `GetAnnouncement` retrieves `UpdatedTime` (or falls back to `CreatedTime`).
+2. **Handler (`internal/http/handler/handler.go`)**: Modify `getAnnouncement` to include an `update_time` (int64) field in its JSON response.
+
+## Frontend Changes (Vite/React/Tailwind)
+1. **API Interface (`src/api/index.ts`)**:
+ * Update `AnnouncementData` to include `update_time: number`.
+2. **Storage Mechanism**:
+ * Use browser `localStorage` to persist the user's view state. Key: `flvx_announcement_seen_time`.
+3. **UI Component (`AnnouncementModal`)**:
+ * Create a new modal component for the dashboard.
+ * The modal content will render the markdown of the announcement.
+ * It will feature two primary actions:
+ * **"Close"**: Closes the modal temporarily for this session (does NOT update `localStorage`). It will pop up again on the next page load.
+ * **"Don't show again"**: Closes the modal AND sets `localStorage.setItem('flvx_announcement_seen_time', announcement.update_time)`.
+4. **Integration (`src/pages/dashboard.tsx` & `use-dashboard-data.ts`)**:
+ * Add state to manage the modal visibility (e.g., `isAnnouncementModalOpen`).
+ * On data load, compare the fetched `update_time` with the stored `flvx_announcement_seen_time`. If the fetched time is greater (or if no stored time exists), set `isAnnouncementModalOpen(true)`.
+
+## Error Handling and Edge Cases
+* If `localStorage` is unavailable or throws an error (e.g., Private Browsing mode restrictions), the modal may show repeatedly. The code should safely catch `localStorage` access errors.
+* If `update_time` is missing from an old database record, the backend should gracefully fall back to the creation time or a safe default (like 0) to ensure the logic doesn't break.
diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go
index 8c6a1e8..815bd58 100644
--- a/go-backend/internal/http/handler/handler.go
+++ b/go-backend/internal/http/handler/handler.go
@@ -1537,15 +1537,22 @@ func (h *Handler) getAnnouncement(w http.ResponseWriter, r *http.Request) {
if ann == nil {
response.WriteJSON(w, response.OK(map[string]interface{}{
- "content": "",
- "enabled": 0,
+ "content": "",
+ "enabled": 0,
+ "update_time": 0,
}))
return
}
+ updateTime := ann.CreatedTime
+ if ann.UpdatedTime.Valid {
+ updateTime = ann.UpdatedTime.Int64
+ }
+
response.WriteJSON(w, response.OK(map[string]interface{}{
- "content": ann.Content,
- "enabled": ann.Enabled,
+ "content": ann.Content,
+ "enabled": ann.Enabled,
+ "update_time": updateTime,
}))
}
diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts
index bd694b0..9dadeea 100644
--- a/vite-frontend/src/api/index.ts
+++ b/vite-frontend/src/api/index.ts
@@ -410,6 +410,7 @@ export const importBackup = (data: BackupImportPayload) =>
export interface AnnouncementData {
content: string;
enabled: number;
+ update_time?: number;
}
export const getAnnouncement = () =>
diff --git a/vite-frontend/src/pages/dashboard.tsx b/vite-frontend/src/pages/dashboard.tsx
index 06ab096..713715f 100644
--- a/vite-frontend/src/pages/dashboard.tsx
+++ b/vite-frontend/src/pages/dashboard.tsx
@@ -12,6 +12,7 @@ import {
} from "@/shadcn-bridge/heroui/modal";
import { PageEmptyState, PageLoadingState } from "@/components/page-state";
import { AnnouncementBanner } from "@/pages/dashboard/components/announcement-banner";
+import { AnnouncementModal } from "@/pages/dashboard/components/announcement-modal";
import { FlowChartCard } from "@/pages/dashboard/components/flow-chart-card";
import { MetricCard } from "@/pages/dashboard/components/metric-card";
import {
@@ -43,6 +44,9 @@ export default function DashboardPage() {
nodeExpiryReminders,
isAdmin,
announcement,
+ isAnnouncementModalOpen,
+ setIsAnnouncementModalOpen,
+ dismissAnnouncementModal,
} = useDashboardData();
const [addressModalOpen, setAddressModalOpen] = useState(false);
@@ -627,6 +631,14 @@ export default function DashboardPage() {
return (
{announcement && }
+ {announcement && (
+ setIsAnnouncementModalOpen(false)}
+ onDontShowAgain={dismissAnnouncementModal}
+ />
+ )}