Compare commits

...

2 Commits

Author SHA1 Message Date
sagit ebf412b9df fix(ui): mobile modal scroll behavior (#412)
* docs: add announcement popup design spec

* docs: add announcement popup implementation plan

* fix(ui): change modal scroll behavior to inside for mobile screens

Fixes layout issue where modal footer and action buttons are pushed off screen on mobile devices.
2026-04-04 19:34:40 +08:00
sagit 9a85363e44 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 13:00:11 +08:00
14 changed files with 426 additions and 29 deletions
@@ -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 (
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
<ModalContent>
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
<ModalBody>
<div className="prose prose-sm dark:prose-invert max-w-none max-h-[60vh] overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{announcement.content}
</ReactMarkdown>
</div>
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={onDontShowAgain}>
不再提示
</Button>
<Button color="primary" onPress={onClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
```
- [ ] **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 && <AnnouncementBanner ... />}` or inside the main `<div>`):
```tsx
{announcement && (
<AnnouncementModal
announcement={announcement}
isOpen={isAnnouncementModalOpen}
onClose={() => 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"
```
@@ -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.
+11 -4
View File
@@ -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,
}))
}
+1
View File
@@ -410,6 +410,7 @@ export const importBackup = (data: BackupImportPayload) =>
export interface AnnouncementData {
content: string;
enabled: number;
update_time?: number;
}
export const getAnnouncement = () =>
+1 -1
View File
@@ -662,7 +662,7 @@ export default function AdminLayout({
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={() => {
onOpenChange();
+13 -1
View File
@@ -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 (
<AnimatedPage className="px-3 lg:px-6 py-2 lg:py-4">
{announcement && <AnnouncementBanner announcement={announcement} />}
{announcement && (
<AnnouncementModal
announcement={announcement}
isOpen={isAnnouncementModalOpen}
onClose={() => setIsAnnouncementModalOpen(false)}
onDontShowAgain={dismissAnnouncementModal}
/>
)}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-6 lg:mb-8">
<MetricCard
icon={
@@ -1091,7 +1103,7 @@ export default function DashboardPage() {
backdrop="blur"
isOpen={addressModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => setAddressModalOpen(false)}
>
@@ -0,0 +1,48 @@
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 (
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
<ModalContent>
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
<ModalBody>
<div className="prose prose-sm dark:prose-invert max-w-none max-h-[60vh] overflow-y-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{announcement.content}
</ReactMarkdown>
</div>
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={onDontShowAgain}>
不再提示
</Button>
<Button color="primary" onPress={onClose}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
@@ -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<AnnouncementData | null>(
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,
};
};
+5 -5
View File
@@ -4702,7 +4702,7 @@ export default function ForwardPage() {
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setModalOpen}
>
@@ -4917,7 +4917,7 @@ export default function ForwardPage() {
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setDeleteModalOpen}
>
@@ -4962,7 +4962,7 @@ export default function ForwardPage() {
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={addressModalOpen}
scrollBehavior="outside"
scrollBehavior="inside"
size="lg"
onClose={() => setAddressModalOpen(false)}
>
@@ -5007,7 +5007,7 @@ export default function ForwardPage() {
}}
isOpen={exportModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => {
setExportModalOpen(false);
@@ -5166,7 +5166,7 @@ export default function ForwardPage() {
}}
isOpen={importModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => setImportModalOpen(false)}
>
+2 -2
View File
@@ -432,7 +432,7 @@ export default function LimitPage() {
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setModalOpen}
>
@@ -510,7 +510,7 @@ export default function LimitPage() {
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setDeleteModalOpen}
>
+6 -6
View File
@@ -2410,7 +2410,7 @@ export default function NodePage() {
}}
isOpen={dialogVisible}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => setDialogVisible(false)}
>
@@ -2831,7 +2831,7 @@ export default function NodePage() {
}}
isOpen={rollbackModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setRollbackModalOpen}
>
@@ -2872,7 +2872,7 @@ export default function NodePage() {
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setDeleteModalOpen}
>
@@ -2968,7 +2968,7 @@ export default function NodePage() {
}}
isOpen={installCommandModal}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={() => setInstallCommandModal(false)}
>
@@ -3025,7 +3025,7 @@ export default function NodePage() {
}}
isOpen={upgradeModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="md"
onOpenChange={setUpgradeModalOpen}
>
@@ -3131,7 +3131,7 @@ export default function NodePage() {
}}
isOpen={batchDeleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="md"
onOpenChange={setBatchDeleteModalOpen}
>
+1 -1
View File
@@ -332,7 +332,7 @@ export default function ProfilePage() {
}}
isOpen={isOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={() => {
onOpenChange();
+2 -2
View File
@@ -2005,7 +2005,7 @@ export default function TunnelPage() {
}}
isOpen={modalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={setModalOpen}
>
@@ -2942,7 +2942,7 @@ export default function TunnelPage() {
}}
isOpen={deleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onOpenChange={handleDeleteModalOpenChange}
>
+7 -7
View File
@@ -1455,7 +1455,7 @@ export default function UserPage() {
}}
isOpen={isUserModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onUserModalClose}
>
@@ -1698,7 +1698,7 @@ export default function UserPage() {
isDismissable={false}
isOpen={isTunnelModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onTunnelModalClose}
>
@@ -2035,7 +2035,7 @@ export default function UserPage() {
isDismissable={false}
isOpen={isEditTunnelModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onEditTunnelModalClose}
>
@@ -2215,7 +2215,7 @@ export default function UserPage() {
}}
isOpen={isDeleteModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onDeleteModalClose}
>
@@ -2261,7 +2261,7 @@ export default function UserPage() {
}}
isOpen={isDeleteTunnelModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onDeleteTunnelModalClose}
>
@@ -2309,7 +2309,7 @@ export default function UserPage() {
}}
isOpen={isResetFlowModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onResetFlowModalClose}
>
@@ -2401,7 +2401,7 @@ export default function UserPage() {
}}
isOpen={isResetTunnelFlowModalOpen}
placement="center"
scrollBehavior="outside"
scrollBehavior="inside"
size="2xl"
onClose={onResetTunnelFlowModalClose}
>