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} + /> + )}
void; + onDontShowAgain: () => void; +} + +export const AnnouncementModal = ({ + announcement, + isOpen, + onClose, + onDontShowAgain, +}: AnnouncementModalProps) => { + return ( + !open && onClose()} size="2xl"> + + 平台公告 + +
+ + {announcement.content} + +
+
+ + + + +
+
+ ); +}; diff --git a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts index f82457e..27dc429 100644 --- a/vite-frontend/src/pages/dashboard/use-dashboard-data.ts +++ b/vite-frontend/src/pages/dashboard/use-dashboard-data.ts @@ -96,6 +96,9 @@ interface DashboardDataState { nodeExpiryReminders: DashboardNodeExpiryItem[]; isAdmin: boolean; announcement: AnnouncementData | null; + isAnnouncementModalOpen: boolean; + setIsAnnouncementModalOpen: (isOpen: boolean) => void; + dismissAnnouncementModal: () => void; } const checkExpirationNotifications = ( @@ -267,6 +270,7 @@ export const useDashboardData = (): DashboardDataState => { const [announcement, setAnnouncement] = useState( null, ); + const [isAnnouncementModalOpen, setIsAnnouncementModalOpen] = useState(false); const isMountedRef = useRef(true); const packageRequestInFlightRef = useRef(false); const nodeExpiryRequestInFlightRef = useRef(false); @@ -345,6 +349,19 @@ export const useDashboardData = (): DashboardDataState => { 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); } @@ -355,6 +372,17 @@ export const useDashboardData = (): DashboardDataState => { } }, []); + 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]); + const loadNodeExpiryData = useCallback(async () => { if (nodeExpiryRequestInFlightRef.current) { return; @@ -438,5 +466,8 @@ export const useDashboardData = (): DashboardDataState => { nodeExpiryReminders, isAdmin, announcement, + isAnnouncementModalOpen, + setIsAnnouncementModalOpen, + dismissAnnouncementModal, }; };