@checkstack/announcement-frontend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +32 -0
- package/src/components/AnnouncementBanner.tsx +244 -0
- package/src/components/AnnouncementMenuItems.tsx +30 -0
- package/src/components/DashboardAnnouncements.tsx +147 -0
- package/src/index.tsx +42 -0
- package/src/pages/AnnouncementManagePage.tsx +618 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @checkstack/announcement-frontend
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- dee86ec: feat: add portal announcement system
|
|
8
|
+
|
|
9
|
+
Introduces a complete announcement system for communicating with portal users:
|
|
10
|
+
|
|
11
|
+
- **announcement-common**: Zod schemas for announcements (severity, visibility, display mode), oRPC contract with 6 procedures (public retrieval, user dismissal, admin CRUD), access rules, and `ANNOUNCEMENT_UPDATED` signal definition
|
|
12
|
+
- **announcement-backend**: Drizzle schema with `announcements` and `announcement_dismissals` tables, router with temporal filtering, visibility control, per-user dismissal persistence, user cleanup hook, real-time signal broadcasting on create/update/delete, and command palette registration ("Create Announcement", "Manage Announcements" with `⇧⌘A` shortcut)
|
|
13
|
+
- **announcement-frontend**: Admin management page with create/edit dialog, global banner component above the navbar (severity-colored, expandable markdown), dashboard cards with compact expand/collapse, admin menu link, and real-time WebSocket signal subscription for instant UI updates
|
|
14
|
+
- **frontend**: Integrates AnnouncementBanner into App.tsx for global visibility
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [dee86ec]
|
|
19
|
+
- @checkstack/announcement-common@0.2.0
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/announcement-frontend",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"checkstack": {
|
|
7
|
+
"type": "frontend"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"lint": "bun run lint:code",
|
|
12
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@checkstack/announcement-common": "0.1.0",
|
|
16
|
+
"@checkstack/auth-frontend": "0.5.17",
|
|
17
|
+
"@checkstack/common": "0.6.4",
|
|
18
|
+
"@checkstack/frontend-api": "0.3.8",
|
|
19
|
+
"@checkstack/signal-frontend": "0.0.14",
|
|
20
|
+
"@checkstack/ui": "1.2.0",
|
|
21
|
+
"date-fns": "^4.1.0",
|
|
22
|
+
"lucide-react": "^0.344.0",
|
|
23
|
+
"react": "^18.2.0",
|
|
24
|
+
"react-router-dom": "^6.20.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.0.0",
|
|
28
|
+
"@types/react": "^18.2.0",
|
|
29
|
+
"@checkstack/tsconfig": "0.0.4",
|
|
30
|
+
"@checkstack/scripts": "0.1.2"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import React, { useState, useCallback } from "react";
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import {
|
|
4
|
+
AnnouncementApi,
|
|
5
|
+
ANNOUNCEMENT_UPDATED,
|
|
6
|
+
type Announcement,
|
|
7
|
+
} from "@checkstack/announcement-common";
|
|
8
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
9
|
+
import { MarkdownBlock } from "@checkstack/ui";
|
|
10
|
+
import {
|
|
11
|
+
Info,
|
|
12
|
+
AlertTriangle,
|
|
13
|
+
AlertOctagon,
|
|
14
|
+
X,
|
|
15
|
+
ChevronDown,
|
|
16
|
+
ChevronUp,
|
|
17
|
+
} from "lucide-react";
|
|
18
|
+
|
|
19
|
+
const DISMISSED_STORAGE_KEY = "checkstack-dismissed-announcements";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns a severity-specific icon component.
|
|
23
|
+
*/
|
|
24
|
+
function SeverityIcon({ severity }: { severity: Announcement["severity"] }) {
|
|
25
|
+
switch (severity) {
|
|
26
|
+
case "critical": {
|
|
27
|
+
return <AlertOctagon className="h-4 w-4 flex-shrink-0" />;
|
|
28
|
+
}
|
|
29
|
+
case "warning": {
|
|
30
|
+
return <AlertTriangle className="h-4 w-4 flex-shrink-0" />;
|
|
31
|
+
}
|
|
32
|
+
default: {
|
|
33
|
+
return <Info className="h-4 w-4 flex-shrink-0" />;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns Tailwind class names for the severity-based banner styling.
|
|
40
|
+
*/
|
|
41
|
+
function getSeverityStyles(severity: Announcement["severity"]): {
|
|
42
|
+
bg: string;
|
|
43
|
+
text: string;
|
|
44
|
+
border: string;
|
|
45
|
+
dismissHover: string;
|
|
46
|
+
} {
|
|
47
|
+
switch (severity) {
|
|
48
|
+
case "critical": {
|
|
49
|
+
return {
|
|
50
|
+
bg: "bg-destructive/10",
|
|
51
|
+
text: "text-destructive",
|
|
52
|
+
border: "border-destructive/20",
|
|
53
|
+
dismissHover: "hover:bg-destructive/20",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
case "warning": {
|
|
57
|
+
return {
|
|
58
|
+
bg: "bg-warning/10",
|
|
59
|
+
text: "text-warning",
|
|
60
|
+
border: "border-warning/20",
|
|
61
|
+
dismissHover: "hover:bg-warning/20",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
default: {
|
|
65
|
+
return {
|
|
66
|
+
bg: "bg-primary/10",
|
|
67
|
+
text: "text-primary",
|
|
68
|
+
border: "border-primary/20",
|
|
69
|
+
dismissHover: "hover:bg-primary/20",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get dismissed announcement IDs from localStorage (for anonymous users).
|
|
77
|
+
*/
|
|
78
|
+
function getLocalDismissedIds(): Set<string> {
|
|
79
|
+
try {
|
|
80
|
+
const stored = localStorage.getItem(DISMISSED_STORAGE_KEY);
|
|
81
|
+
if (!stored) return new Set();
|
|
82
|
+
const ids: unknown = JSON.parse(stored);
|
|
83
|
+
if (Array.isArray(ids)) {
|
|
84
|
+
return new Set(ids.filter((id): id is string => typeof id === "string"));
|
|
85
|
+
}
|
|
86
|
+
return new Set();
|
|
87
|
+
} catch {
|
|
88
|
+
return new Set();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Add an announcement ID to the localStorage dismissed set.
|
|
94
|
+
*/
|
|
95
|
+
function saveLocalDismissedId(id: string) {
|
|
96
|
+
const current = getLocalDismissedIds();
|
|
97
|
+
current.add(id);
|
|
98
|
+
localStorage.setItem(DISMISSED_STORAGE_KEY, JSON.stringify([...current]));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A single banner strip for one announcement.
|
|
103
|
+
*/
|
|
104
|
+
function BannerItem({
|
|
105
|
+
announcement,
|
|
106
|
+
onDismiss,
|
|
107
|
+
}: {
|
|
108
|
+
announcement: Announcement;
|
|
109
|
+
onDismiss: (id: string) => void;
|
|
110
|
+
}) {
|
|
111
|
+
const [expanded, setExpanded] = useState(false);
|
|
112
|
+
const styles = getSeverityStyles(announcement.severity);
|
|
113
|
+
|
|
114
|
+
// Only show expand toggle if the message is longer than just the title
|
|
115
|
+
const hasExpandableContent = announcement.message.length > 0;
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div
|
|
119
|
+
className={`${styles.bg} ${styles.border} border-b px-4 py-2 transition-all duration-200`}
|
|
120
|
+
>
|
|
121
|
+
<div className="flex items-center gap-3 max-w-7xl mx-auto">
|
|
122
|
+
<SeverityIcon severity={announcement.severity} />
|
|
123
|
+
|
|
124
|
+
<span className={`text-sm font-medium ${styles.text} flex-1`}>
|
|
125
|
+
{announcement.title}
|
|
126
|
+
</span>
|
|
127
|
+
|
|
128
|
+
{hasExpandableContent && (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => setExpanded(!expanded)}
|
|
132
|
+
className={`${styles.text} p-1 rounded ${styles.dismissHover} transition-colors`}
|
|
133
|
+
aria-label={expanded ? "Collapse details" : "Expand details"}
|
|
134
|
+
>
|
|
135
|
+
{expanded ? (
|
|
136
|
+
<ChevronUp className="h-4 w-4" />
|
|
137
|
+
) : (
|
|
138
|
+
<ChevronDown className="h-4 w-4" />
|
|
139
|
+
)}
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={() => onDismiss(announcement.id)}
|
|
146
|
+
className={`${styles.text} p-1 rounded ${styles.dismissHover} transition-colors`}
|
|
147
|
+
aria-label="Dismiss announcement"
|
|
148
|
+
>
|
|
149
|
+
<X className="h-4 w-4" />
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{expanded && hasExpandableContent && (
|
|
154
|
+
<div className="max-w-7xl mx-auto mt-2 pl-7 pb-1">
|
|
155
|
+
<div className="text-sm opacity-90">
|
|
156
|
+
<MarkdownBlock size="sm">{announcement.message}</MarkdownBlock>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Severity sort priority for sorting banners (critical first).
|
|
166
|
+
*/
|
|
167
|
+
const SEVERITY_ORDER: Record<string, number> = {
|
|
168
|
+
critical: 0,
|
|
169
|
+
warning: 1,
|
|
170
|
+
info: 2,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Global announcement banner component.
|
|
175
|
+
* Renders active banner announcements as slim strips above the navbar.
|
|
176
|
+
*
|
|
177
|
+
* Must be rendered in App.tsx outside of auth-gated areas since it needs
|
|
178
|
+
* to work for unauthenticated users viewing public announcements.
|
|
179
|
+
*/
|
|
180
|
+
export const AnnouncementBanner: React.FC = () => {
|
|
181
|
+
const announcementClient = usePluginClient(AnnouncementApi);
|
|
182
|
+
const [localDismissedIds, setLocalDismissedIds] = useState<Set<string>>(() =>
|
|
183
|
+
getLocalDismissedIds(),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const { data, isLoading, refetch } =
|
|
187
|
+
announcementClient.getActiveAnnouncements.useQuery();
|
|
188
|
+
|
|
189
|
+
// Refetch own query immediately + invalidate all announcement queries
|
|
190
|
+
// (including the dashboard's includeDismissed variant) for cross-component freshness.
|
|
191
|
+
// oRPC query keys are structured as [[...path], { type, input }].
|
|
192
|
+
useSignal(ANNOUNCEMENT_UPDATED, () => {
|
|
193
|
+
void refetch();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Server-side dismiss — extract stable mutate function to avoid re-renders
|
|
197
|
+
const { mutate: dismissOnServer } =
|
|
198
|
+
announcementClient.dismissAnnouncement.useMutation({});
|
|
199
|
+
|
|
200
|
+
// Filter to banner-visible announcements
|
|
201
|
+
const bannerAnnouncements = React.useMemo(() => {
|
|
202
|
+
if (!data?.announcements) return [];
|
|
203
|
+
|
|
204
|
+
return data.announcements
|
|
205
|
+
.filter((a) => a.displayMode === "banner" || a.displayMode === "both")
|
|
206
|
+
.filter((a) => !localDismissedIds.has(a.id))
|
|
207
|
+
.toSorted(
|
|
208
|
+
(a, b) =>
|
|
209
|
+
(SEVERITY_ORDER[a.severity] ?? 2) - (SEVERITY_ORDER[b.severity] ?? 2),
|
|
210
|
+
);
|
|
211
|
+
}, [data?.announcements, localDismissedIds]);
|
|
212
|
+
|
|
213
|
+
const handleDismiss = useCallback(
|
|
214
|
+
(id: string) => {
|
|
215
|
+
// Always save to localStorage (works for anonymous and authenticated)
|
|
216
|
+
saveLocalDismissedId(id);
|
|
217
|
+
setLocalDismissedIds((prev) => {
|
|
218
|
+
const next = new Set(prev);
|
|
219
|
+
next.add(id);
|
|
220
|
+
return next;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Also dismiss server-side (fire-and-forget, silently fails for anonymous)
|
|
224
|
+
dismissOnServer({ announcementId: id });
|
|
225
|
+
},
|
|
226
|
+
[dismissOnServer],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
if (isLoading || bannerAnnouncements.length === 0) {
|
|
230
|
+
return <></>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className="w-full">
|
|
235
|
+
{bannerAnnouncements.map((announcement) => (
|
|
236
|
+
<BannerItem
|
|
237
|
+
key={announcement.id}
|
|
238
|
+
announcement={announcement}
|
|
239
|
+
onDismiss={handleDismiss}
|
|
240
|
+
/>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { Megaphone } from "lucide-react";
|
|
4
|
+
import type { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
5
|
+
import { DropdownMenuItem } from "@checkstack/ui";
|
|
6
|
+
import { resolveRoute } from "@checkstack/common";
|
|
7
|
+
import {
|
|
8
|
+
announcementRoutes,
|
|
9
|
+
announcementAccess,
|
|
10
|
+
pluginMetadata,
|
|
11
|
+
} from "@checkstack/announcement-common";
|
|
12
|
+
|
|
13
|
+
export const AnnouncementMenuItems = ({
|
|
14
|
+
accessRules: userPerms,
|
|
15
|
+
}: UserMenuItemsContext) => {
|
|
16
|
+
const qualifiedId = `${pluginMetadata.pluginId}.${announcementAccess.manage.id}`;
|
|
17
|
+
const canManage = userPerms.includes("*") || userPerms.includes(qualifiedId);
|
|
18
|
+
|
|
19
|
+
if (!canManage) {
|
|
20
|
+
return <React.Fragment />;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Link to={resolveRoute(announcementRoutes.routes.manage)}>
|
|
25
|
+
<DropdownMenuItem icon={<Megaphone className="w-4 h-4" />}>
|
|
26
|
+
Announcements
|
|
27
|
+
</DropdownMenuItem>
|
|
28
|
+
</Link>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
3
|
+
import { AnnouncementApi, ANNOUNCEMENT_UPDATED, type Announcement } from "@checkstack/announcement-common";
|
|
4
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
5
|
+
import { MarkdownBlock } from "@checkstack/ui";
|
|
6
|
+
import {
|
|
7
|
+
Info,
|
|
8
|
+
AlertTriangle,
|
|
9
|
+
AlertOctagon,
|
|
10
|
+
ChevronDown,
|
|
11
|
+
ChevronUp,
|
|
12
|
+
Megaphone,
|
|
13
|
+
} from "lucide-react";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns the right icon for a severity level.
|
|
17
|
+
*/
|
|
18
|
+
function SeverityIcon({
|
|
19
|
+
severity,
|
|
20
|
+
className,
|
|
21
|
+
}: {
|
|
22
|
+
severity: Announcement["severity"];
|
|
23
|
+
className?: string;
|
|
24
|
+
}) {
|
|
25
|
+
switch (severity) {
|
|
26
|
+
case "critical": {
|
|
27
|
+
return <AlertOctagon className={className} />;
|
|
28
|
+
}
|
|
29
|
+
case "warning": {
|
|
30
|
+
return <AlertTriangle className={className} />;
|
|
31
|
+
}
|
|
32
|
+
default: {
|
|
33
|
+
return <Info className={className} />;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Severity to border/accent color mapping.
|
|
40
|
+
*/
|
|
41
|
+
function getSeverityColor(severity: Announcement["severity"]): string {
|
|
42
|
+
switch (severity) {
|
|
43
|
+
case "critical": {
|
|
44
|
+
return "border-l-destructive text-destructive";
|
|
45
|
+
}
|
|
46
|
+
case "warning": {
|
|
47
|
+
return "border-l-warning text-warning";
|
|
48
|
+
}
|
|
49
|
+
default: {
|
|
50
|
+
return "border-l-primary text-primary";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A single compact announcement card with expand/collapse.
|
|
57
|
+
*/
|
|
58
|
+
function AnnouncementCard({ announcement }: { announcement: Announcement }) {
|
|
59
|
+
const [expanded, setExpanded] = useState(false);
|
|
60
|
+
const severityColor = getSeverityColor(announcement.severity);
|
|
61
|
+
const [borderClass, textClass] = severityColor.split(" ");
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
className={`bg-card border border-border rounded-lg border-l-4 ${borderClass} transition-all duration-200`}
|
|
66
|
+
>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={() => setExpanded(!expanded)}
|
|
70
|
+
className="w-full px-4 py-3 flex items-center gap-3 text-left hover:bg-muted/50 rounded-r-lg transition-colors"
|
|
71
|
+
>
|
|
72
|
+
<SeverityIcon
|
|
73
|
+
severity={announcement.severity}
|
|
74
|
+
className={`h-4 w-4 flex-shrink-0 ${textClass}`}
|
|
75
|
+
/>
|
|
76
|
+
<span className="text-sm font-medium text-foreground flex-1 truncate">
|
|
77
|
+
{announcement.title}
|
|
78
|
+
</span>
|
|
79
|
+
{expanded ? (
|
|
80
|
+
<ChevronUp className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
81
|
+
) : (
|
|
82
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
83
|
+
)}
|
|
84
|
+
</button>
|
|
85
|
+
|
|
86
|
+
{expanded && (
|
|
87
|
+
<div className="px-4 pb-3 pl-11 animate-in slide-in-from-top-1 duration-200">
|
|
88
|
+
<div className="text-sm text-muted-foreground">
|
|
89
|
+
<MarkdownBlock size="sm">{announcement.message}</MarkdownBlock>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Dashboard announcement section.
|
|
99
|
+
* Renders inside the Overview section as compact, expandable cards.
|
|
100
|
+
* Only shows when there are active dashboard-mode announcements.
|
|
101
|
+
*/
|
|
102
|
+
export const DashboardAnnouncements: React.FC = () => {
|
|
103
|
+
const announcementClient = usePluginClient(AnnouncementApi);
|
|
104
|
+
|
|
105
|
+
// Always refetch on mount so the dashboard shows fresh data when navigated to.
|
|
106
|
+
// Also subscribe to signals for instant updates while actively viewing.
|
|
107
|
+
const { data, isLoading, refetch } =
|
|
108
|
+
announcementClient.getActiveAnnouncements.useQuery(
|
|
109
|
+
{ includeDismissed: true },
|
|
110
|
+
{ refetchOnMount: "always" },
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
useSignal(ANNOUNCEMENT_UPDATED, () => {
|
|
114
|
+
void refetch();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const dashboardAnnouncements = React.useMemo(() => {
|
|
118
|
+
if (!data?.announcements) return [];
|
|
119
|
+
|
|
120
|
+
return data.announcements.filter(
|
|
121
|
+
(a) => a.displayMode === "dashboard" || a.displayMode === "both",
|
|
122
|
+
);
|
|
123
|
+
}, [data?.announcements]);
|
|
124
|
+
|
|
125
|
+
if (isLoading || dashboardAnnouncements.length === 0) {
|
|
126
|
+
return <></>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<section className="animate-in fade-in duration-300">
|
|
131
|
+
<div className="flex items-center gap-2 mb-3">
|
|
132
|
+
<Megaphone className="w-4 h-4 text-muted-foreground" />
|
|
133
|
+
<h3 className="text-sm font-medium text-muted-foreground">
|
|
134
|
+
Announcements
|
|
135
|
+
</h3>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="space-y-2">
|
|
138
|
+
{dashboardAnnouncements.map((announcement) => (
|
|
139
|
+
<AnnouncementCard
|
|
140
|
+
key={announcement.id}
|
|
141
|
+
announcement={announcement}
|
|
142
|
+
/>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
</section>
|
|
146
|
+
);
|
|
147
|
+
};
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrontendPlugin,
|
|
3
|
+
createSlotExtension,
|
|
4
|
+
UserMenuItemsSlot,
|
|
5
|
+
DashboardSlot,
|
|
6
|
+
} from "@checkstack/frontend-api";
|
|
7
|
+
import {
|
|
8
|
+
announcementRoutes,
|
|
9
|
+
pluginMetadata,
|
|
10
|
+
announcementAccess,
|
|
11
|
+
} from "@checkstack/announcement-common";
|
|
12
|
+
import { AnnouncementManagePage } from "./pages/AnnouncementManagePage";
|
|
13
|
+
import { AnnouncementMenuItems } from "./components/AnnouncementMenuItems";
|
|
14
|
+
import { DashboardAnnouncements } from "./components/DashboardAnnouncements";
|
|
15
|
+
|
|
16
|
+
export default createFrontendPlugin({
|
|
17
|
+
metadata: pluginMetadata,
|
|
18
|
+
routes: [
|
|
19
|
+
{
|
|
20
|
+
route: announcementRoutes.routes.manage,
|
|
21
|
+
element: <AnnouncementManagePage />,
|
|
22
|
+
title: "Manage Announcements",
|
|
23
|
+
accessRule: announcementAccess.manage,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
apis: [],
|
|
27
|
+
extensions: [
|
|
28
|
+
createSlotExtension(UserMenuItemsSlot, {
|
|
29
|
+
id: "announcement.user-menu.items",
|
|
30
|
+
component: AnnouncementMenuItems,
|
|
31
|
+
}),
|
|
32
|
+
{
|
|
33
|
+
id: "announcement.dashboard.cards",
|
|
34
|
+
slot: DashboardSlot,
|
|
35
|
+
component:
|
|
36
|
+
DashboardAnnouncements as React.ComponentType<unknown>,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Re-export components for direct use in App.tsx
|
|
42
|
+
export { AnnouncementBanner } from "./components/AnnouncementBanner";
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
usePluginClient,
|
|
4
|
+
accessApiRef,
|
|
5
|
+
useApi,
|
|
6
|
+
wrapInSuspense,
|
|
7
|
+
} from "@checkstack/frontend-api";
|
|
8
|
+
import {
|
|
9
|
+
AnnouncementApi,
|
|
10
|
+
announcementAccess,
|
|
11
|
+
type Announcement,
|
|
12
|
+
type AnnouncementSeverity,
|
|
13
|
+
type AnnouncementVisibility,
|
|
14
|
+
type AnnouncementDisplayMode,
|
|
15
|
+
} from "@checkstack/announcement-common";
|
|
16
|
+
import {
|
|
17
|
+
PageLayout,
|
|
18
|
+
Card,
|
|
19
|
+
CardHeader,
|
|
20
|
+
CardTitle,
|
|
21
|
+
CardContent,
|
|
22
|
+
Button,
|
|
23
|
+
Badge,
|
|
24
|
+
LoadingSpinner,
|
|
25
|
+
EmptyState,
|
|
26
|
+
Table,
|
|
27
|
+
TableHeader,
|
|
28
|
+
TableRow,
|
|
29
|
+
TableHead,
|
|
30
|
+
TableBody,
|
|
31
|
+
TableCell,
|
|
32
|
+
Select,
|
|
33
|
+
SelectTrigger,
|
|
34
|
+
SelectValue,
|
|
35
|
+
SelectContent,
|
|
36
|
+
SelectItem,
|
|
37
|
+
useToast,
|
|
38
|
+
ConfirmationModal,
|
|
39
|
+
Dialog,
|
|
40
|
+
DialogContent,
|
|
41
|
+
DialogHeader,
|
|
42
|
+
DialogTitle,
|
|
43
|
+
DialogDescription,
|
|
44
|
+
DialogFooter,
|
|
45
|
+
Input,
|
|
46
|
+
Label,
|
|
47
|
+
Textarea,
|
|
48
|
+
} from "@checkstack/ui";
|
|
49
|
+
import {
|
|
50
|
+
Plus,
|
|
51
|
+
Megaphone,
|
|
52
|
+
Trash2,
|
|
53
|
+
Edit2,
|
|
54
|
+
Clock,
|
|
55
|
+
Eye,
|
|
56
|
+
EyeOff,
|
|
57
|
+
Monitor,
|
|
58
|
+
LayoutDashboard,
|
|
59
|
+
Columns,
|
|
60
|
+
} from "lucide-react";
|
|
61
|
+
import { formatDistanceToNow, format } from "date-fns";
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Editor Dialog
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
interface AnnouncementEditorProps {
|
|
68
|
+
open: boolean;
|
|
69
|
+
onOpenChange: (open: boolean) => void;
|
|
70
|
+
announcement?: Announcement;
|
|
71
|
+
onSave: () => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const AnnouncementEditor: React.FC<AnnouncementEditorProps> = ({
|
|
75
|
+
open,
|
|
76
|
+
onOpenChange,
|
|
77
|
+
announcement,
|
|
78
|
+
onSave,
|
|
79
|
+
}) => {
|
|
80
|
+
const announcementClient = usePluginClient(AnnouncementApi);
|
|
81
|
+
const toast = useToast();
|
|
82
|
+
const isEdit = !!announcement;
|
|
83
|
+
|
|
84
|
+
const [title, setTitle] = useState(announcement?.title ?? "");
|
|
85
|
+
const [message, setMessage] = useState(announcement?.message ?? "");
|
|
86
|
+
const [severity, setSeverity] = useState<AnnouncementSeverity>(
|
|
87
|
+
announcement?.severity ?? "info",
|
|
88
|
+
);
|
|
89
|
+
const [visibility, setVisibility] = useState<AnnouncementVisibility>(
|
|
90
|
+
announcement?.visibility ?? "all",
|
|
91
|
+
);
|
|
92
|
+
const [displayMode, setDisplayMode] = useState<AnnouncementDisplayMode>(
|
|
93
|
+
announcement?.displayMode ?? "both",
|
|
94
|
+
);
|
|
95
|
+
const [active, setActive] = useState(announcement?.active ?? true);
|
|
96
|
+
const [startsAt, setStartsAt] = useState(
|
|
97
|
+
announcement?.startsAt
|
|
98
|
+
? format(new Date(announcement.startsAt), "yyyy-MM-dd'T'HH:mm")
|
|
99
|
+
: "",
|
|
100
|
+
);
|
|
101
|
+
const [expiresAt, setExpiresAt] = useState(
|
|
102
|
+
announcement?.expiresAt
|
|
103
|
+
? format(new Date(announcement.expiresAt), "yyyy-MM-dd'T'HH:mm")
|
|
104
|
+
: "",
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Reset form when dialog opens with new data
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
if (open) {
|
|
110
|
+
setTitle(announcement?.title ?? "");
|
|
111
|
+
setMessage(announcement?.message ?? "");
|
|
112
|
+
setSeverity(announcement?.severity ?? "info");
|
|
113
|
+
setVisibility(announcement?.visibility ?? "all");
|
|
114
|
+
setDisplayMode(announcement?.displayMode ?? "both");
|
|
115
|
+
setActive(announcement?.active ?? true);
|
|
116
|
+
setStartsAt(
|
|
117
|
+
announcement?.startsAt
|
|
118
|
+
? format(new Date(announcement.startsAt), "yyyy-MM-dd'T'HH:mm")
|
|
119
|
+
: "",
|
|
120
|
+
);
|
|
121
|
+
setExpiresAt(
|
|
122
|
+
announcement?.expiresAt
|
|
123
|
+
? format(new Date(announcement.expiresAt), "yyyy-MM-dd'T'HH:mm")
|
|
124
|
+
: "",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}, [open, announcement]);
|
|
128
|
+
|
|
129
|
+
const createMutation = announcementClient.createAnnouncement.useMutation({
|
|
130
|
+
onSuccess: () => {
|
|
131
|
+
toast.success("Announcement created");
|
|
132
|
+
onOpenChange(false);
|
|
133
|
+
onSave();
|
|
134
|
+
},
|
|
135
|
+
onError: (error) => {
|
|
136
|
+
toast.error(error instanceof Error ? error.message : "Failed to create");
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const updateMutation = announcementClient.updateAnnouncement.useMutation({
|
|
141
|
+
onSuccess: () => {
|
|
142
|
+
toast.success("Announcement updated");
|
|
143
|
+
onOpenChange(false);
|
|
144
|
+
onSave();
|
|
145
|
+
},
|
|
146
|
+
onError: (error) => {
|
|
147
|
+
toast.error(error instanceof Error ? error.message : "Failed to update");
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
|
|
154
|
+
const data = {
|
|
155
|
+
title,
|
|
156
|
+
message,
|
|
157
|
+
severity,
|
|
158
|
+
visibility,
|
|
159
|
+
displayMode,
|
|
160
|
+
active,
|
|
161
|
+
startsAt: startsAt ? new Date(startsAt) : undefined,
|
|
162
|
+
expiresAt: expiresAt ? new Date(expiresAt) : undefined,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (isEdit && announcement) {
|
|
166
|
+
updateMutation.mutate({ id: announcement.id, ...data });
|
|
167
|
+
} else {
|
|
168
|
+
createMutation.mutate(data);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const isPending = createMutation.isPending || updateMutation.isPending;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
176
|
+
<DialogContent size="lg">
|
|
177
|
+
<DialogHeader>
|
|
178
|
+
<DialogTitle>
|
|
179
|
+
{isEdit ? "Edit Announcement" : "Create Announcement"}
|
|
180
|
+
</DialogTitle>
|
|
181
|
+
<DialogDescription>
|
|
182
|
+
{isEdit
|
|
183
|
+
? "Update the announcement details below."
|
|
184
|
+
: "Create a new announcement to display in the portal."}
|
|
185
|
+
</DialogDescription>
|
|
186
|
+
</DialogHeader>
|
|
187
|
+
|
|
188
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
189
|
+
{/* Title */}
|
|
190
|
+
<div className="space-y-2">
|
|
191
|
+
<Label htmlFor="ann-title">Title</Label>
|
|
192
|
+
<Input
|
|
193
|
+
id="ann-title"
|
|
194
|
+
value={title}
|
|
195
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
196
|
+
placeholder="Announcement title"
|
|
197
|
+
required
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{/* Message (Markdown) */}
|
|
202
|
+
<div className="space-y-2">
|
|
203
|
+
<Label htmlFor="ann-message">Message (Markdown)</Label>
|
|
204
|
+
<Textarea
|
|
205
|
+
id="ann-message"
|
|
206
|
+
value={message}
|
|
207
|
+
onChange={(e) => setMessage(e.target.value)}
|
|
208
|
+
placeholder="Write your announcement message in Markdown..."
|
|
209
|
+
rows={6}
|
|
210
|
+
required
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Severity + Visibility + Display Mode row */}
|
|
215
|
+
<div className="grid grid-cols-3 gap-4">
|
|
216
|
+
<div className="space-y-2">
|
|
217
|
+
<Label>Severity</Label>
|
|
218
|
+
<Select
|
|
219
|
+
value={severity}
|
|
220
|
+
onValueChange={(v) => setSeverity(v as AnnouncementSeverity)}
|
|
221
|
+
>
|
|
222
|
+
<SelectTrigger>
|
|
223
|
+
<SelectValue />
|
|
224
|
+
</SelectTrigger>
|
|
225
|
+
<SelectContent>
|
|
226
|
+
<SelectItem value="info">Info</SelectItem>
|
|
227
|
+
<SelectItem value="warning">Warning</SelectItem>
|
|
228
|
+
<SelectItem value="critical">Critical</SelectItem>
|
|
229
|
+
</SelectContent>
|
|
230
|
+
</Select>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="space-y-2">
|
|
234
|
+
<Label>Visibility</Label>
|
|
235
|
+
<Select
|
|
236
|
+
value={visibility}
|
|
237
|
+
onValueChange={(v) =>
|
|
238
|
+
setVisibility(v as AnnouncementVisibility)
|
|
239
|
+
}
|
|
240
|
+
>
|
|
241
|
+
<SelectTrigger>
|
|
242
|
+
<SelectValue />
|
|
243
|
+
</SelectTrigger>
|
|
244
|
+
<SelectContent>
|
|
245
|
+
<SelectItem value="all">Everyone</SelectItem>
|
|
246
|
+
<SelectItem value="authenticated">
|
|
247
|
+
Authenticated Only
|
|
248
|
+
</SelectItem>
|
|
249
|
+
</SelectContent>
|
|
250
|
+
</Select>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div className="space-y-2">
|
|
254
|
+
<Label>Display Mode</Label>
|
|
255
|
+
<Select
|
|
256
|
+
value={displayMode}
|
|
257
|
+
onValueChange={(v) =>
|
|
258
|
+
setDisplayMode(v as AnnouncementDisplayMode)
|
|
259
|
+
}
|
|
260
|
+
>
|
|
261
|
+
<SelectTrigger>
|
|
262
|
+
<SelectValue />
|
|
263
|
+
</SelectTrigger>
|
|
264
|
+
<SelectContent>
|
|
265
|
+
<SelectItem value="banner">Banner Only</SelectItem>
|
|
266
|
+
<SelectItem value="dashboard">Dashboard Only</SelectItem>
|
|
267
|
+
<SelectItem value="both">Both</SelectItem>
|
|
268
|
+
</SelectContent>
|
|
269
|
+
</Select>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{/* Active toggle + date scheduling */}
|
|
274
|
+
<div className="grid grid-cols-3 gap-4">
|
|
275
|
+
<div className="space-y-2">
|
|
276
|
+
<Label>Status</Label>
|
|
277
|
+
<Select
|
|
278
|
+
value={active ? "active" : "inactive"}
|
|
279
|
+
onValueChange={(v) => setActive(v === "active")}
|
|
280
|
+
>
|
|
281
|
+
<SelectTrigger>
|
|
282
|
+
<SelectValue />
|
|
283
|
+
</SelectTrigger>
|
|
284
|
+
<SelectContent>
|
|
285
|
+
<SelectItem value="active">Active</SelectItem>
|
|
286
|
+
<SelectItem value="inactive">Inactive</SelectItem>
|
|
287
|
+
</SelectContent>
|
|
288
|
+
</Select>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<div className="space-y-2">
|
|
292
|
+
<Label htmlFor="ann-starts">Starts At (optional)</Label>
|
|
293
|
+
<Input
|
|
294
|
+
id="ann-starts"
|
|
295
|
+
type="datetime-local"
|
|
296
|
+
value={startsAt}
|
|
297
|
+
onChange={(e) => setStartsAt(e.target.value)}
|
|
298
|
+
/>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
<div className="space-y-2">
|
|
302
|
+
<Label htmlFor="ann-expires">Expires At (optional)</Label>
|
|
303
|
+
<Input
|
|
304
|
+
id="ann-expires"
|
|
305
|
+
type="datetime-local"
|
|
306
|
+
value={expiresAt}
|
|
307
|
+
onChange={(e) => setExpiresAt(e.target.value)}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<DialogFooter>
|
|
313
|
+
<Button
|
|
314
|
+
type="button"
|
|
315
|
+
variant="outline"
|
|
316
|
+
onClick={() => onOpenChange(false)}
|
|
317
|
+
>
|
|
318
|
+
Cancel
|
|
319
|
+
</Button>
|
|
320
|
+
<Button type="submit" disabled={isPending}>
|
|
321
|
+
{isPending
|
|
322
|
+
? "Saving..."
|
|
323
|
+
: isEdit
|
|
324
|
+
? "Update"
|
|
325
|
+
: "Create"}
|
|
326
|
+
</Button>
|
|
327
|
+
</DialogFooter>
|
|
328
|
+
</form>
|
|
329
|
+
</DialogContent>
|
|
330
|
+
</Dialog>
|
|
331
|
+
);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Status computation
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
function getAnnouncementStatus(
|
|
339
|
+
announcement: Announcement,
|
|
340
|
+
): "active" | "scheduled" | "expired" | "inactive" {
|
|
341
|
+
if (!announcement.active) return "inactive";
|
|
342
|
+
|
|
343
|
+
const now = new Date();
|
|
344
|
+
|
|
345
|
+
if (announcement.startsAt && new Date(announcement.startsAt) > now) {
|
|
346
|
+
return "scheduled";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (announcement.expiresAt && new Date(announcement.expiresAt) <= now) {
|
|
350
|
+
return "expired";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return "active";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function StatusBadge({ announcement }: { announcement: Announcement }) {
|
|
357
|
+
const status = getAnnouncementStatus(announcement);
|
|
358
|
+
|
|
359
|
+
switch (status) {
|
|
360
|
+
case "active": {
|
|
361
|
+
return <Badge variant="success">Active</Badge>;
|
|
362
|
+
}
|
|
363
|
+
case "scheduled": {
|
|
364
|
+
return <Badge variant="info">Scheduled</Badge>;
|
|
365
|
+
}
|
|
366
|
+
case "expired": {
|
|
367
|
+
return <Badge variant="secondary">Expired</Badge>;
|
|
368
|
+
}
|
|
369
|
+
case "inactive": {
|
|
370
|
+
return <Badge variant="secondary">Inactive</Badge>;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function SeverityBadge({ severity }: { severity: AnnouncementSeverity }) {
|
|
376
|
+
switch (severity) {
|
|
377
|
+
case "critical": {
|
|
378
|
+
return <Badge variant="destructive">Critical</Badge>;
|
|
379
|
+
}
|
|
380
|
+
case "warning": {
|
|
381
|
+
return <Badge variant="warning">Warning</Badge>;
|
|
382
|
+
}
|
|
383
|
+
default: {
|
|
384
|
+
return <Badge variant="info">Info</Badge>;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function DisplayModeIcon({ mode }: { mode: AnnouncementDisplayMode }) {
|
|
390
|
+
switch (mode) {
|
|
391
|
+
case "banner": {
|
|
392
|
+
return (
|
|
393
|
+
<span title="Banner">
|
|
394
|
+
<Monitor className="h-4 w-4 text-muted-foreground" />
|
|
395
|
+
</span>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
case "dashboard": {
|
|
399
|
+
return (
|
|
400
|
+
<span title="Dashboard">
|
|
401
|
+
<LayoutDashboard className="h-4 w-4 text-muted-foreground" />
|
|
402
|
+
</span>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
case "both": {
|
|
406
|
+
return (
|
|
407
|
+
<span title="Both">
|
|
408
|
+
<Columns className="h-4 w-4 text-muted-foreground" />
|
|
409
|
+
</span>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function VisibilityIcon({
|
|
416
|
+
visibility,
|
|
417
|
+
}: {
|
|
418
|
+
visibility: AnnouncementVisibility;
|
|
419
|
+
}) {
|
|
420
|
+
switch (visibility) {
|
|
421
|
+
case "all": {
|
|
422
|
+
return (
|
|
423
|
+
<span title="Everyone">
|
|
424
|
+
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
425
|
+
</span>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
case "authenticated": {
|
|
429
|
+
return (
|
|
430
|
+
<span title="Authenticated only">
|
|
431
|
+
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
|
432
|
+
</span>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Main Page
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
const AnnouncementManageContent: React.FC = () => {
|
|
443
|
+
const announcementClient = usePluginClient(AnnouncementApi);
|
|
444
|
+
const accessApi = useApi(accessApiRef);
|
|
445
|
+
const toast = useToast();
|
|
446
|
+
|
|
447
|
+
const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
|
|
448
|
+
announcementAccess.manage,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const [editorOpen, setEditorOpen] = useState(false);
|
|
452
|
+
const [editingAnnouncement, setEditingAnnouncement] = useState<
|
|
453
|
+
Announcement | undefined
|
|
454
|
+
>();
|
|
455
|
+
const [deleteId, setDeleteId] = useState<string | undefined>();
|
|
456
|
+
|
|
457
|
+
const {
|
|
458
|
+
data: announcementsData,
|
|
459
|
+
isLoading,
|
|
460
|
+
refetch,
|
|
461
|
+
} = announcementClient.listAllAnnouncements.useQuery();
|
|
462
|
+
|
|
463
|
+
const deleteMutation = announcementClient.deleteAnnouncement.useMutation({
|
|
464
|
+
onSuccess: () => {
|
|
465
|
+
toast.success("Announcement deleted");
|
|
466
|
+
void refetch();
|
|
467
|
+
setDeleteId(undefined);
|
|
468
|
+
},
|
|
469
|
+
onError: (error) => {
|
|
470
|
+
toast.error(error instanceof Error ? error.message : "Failed to delete");
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const announcements = announcementsData?.announcements ?? [];
|
|
475
|
+
|
|
476
|
+
const handleCreate = () => {
|
|
477
|
+
setEditingAnnouncement(undefined);
|
|
478
|
+
setEditorOpen(true);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const handleEdit = (a: Announcement) => {
|
|
482
|
+
setEditingAnnouncement(a);
|
|
483
|
+
setEditorOpen(true);
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const handleDelete = () => {
|
|
487
|
+
if (!deleteId) return;
|
|
488
|
+
deleteMutation.mutate({ id: deleteId });
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const handleSave = () => {
|
|
492
|
+
void refetch();
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<PageLayout
|
|
497
|
+
title="Announcement Management"
|
|
498
|
+
subtitle="Create and manage portal announcements for your users"
|
|
499
|
+
icon={Megaphone}
|
|
500
|
+
loading={accessLoading}
|
|
501
|
+
allowed={canManage}
|
|
502
|
+
actions={
|
|
503
|
+
<Button onClick={handleCreate}>
|
|
504
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
505
|
+
New Announcement
|
|
506
|
+
</Button>
|
|
507
|
+
}
|
|
508
|
+
>
|
|
509
|
+
<Card>
|
|
510
|
+
<CardHeader className="border-b border-border">
|
|
511
|
+
<div className="flex items-center gap-2">
|
|
512
|
+
<Megaphone className="h-5 w-5 text-muted-foreground" />
|
|
513
|
+
<CardTitle>Announcements</CardTitle>
|
|
514
|
+
</div>
|
|
515
|
+
</CardHeader>
|
|
516
|
+
<CardContent className="p-0">
|
|
517
|
+
{isLoading ? (
|
|
518
|
+
<div className="p-12 flex justify-center">
|
|
519
|
+
<LoadingSpinner />
|
|
520
|
+
</div>
|
|
521
|
+
) : announcements.length === 0 ? (
|
|
522
|
+
<EmptyState
|
|
523
|
+
title="No announcements yet"
|
|
524
|
+
description="Create your first announcement to inform users about important updates."
|
|
525
|
+
/>
|
|
526
|
+
) : (
|
|
527
|
+
<Table>
|
|
528
|
+
<TableHeader>
|
|
529
|
+
<TableRow>
|
|
530
|
+
<TableHead>Title</TableHead>
|
|
531
|
+
<TableHead>Severity</TableHead>
|
|
532
|
+
<TableHead>Status</TableHead>
|
|
533
|
+
<TableHead>Display</TableHead>
|
|
534
|
+
<TableHead>Visibility</TableHead>
|
|
535
|
+
<TableHead>Created</TableHead>
|
|
536
|
+
<TableHead className="w-24">Actions</TableHead>
|
|
537
|
+
</TableRow>
|
|
538
|
+
</TableHeader>
|
|
539
|
+
<TableBody>
|
|
540
|
+
{announcements.map((a) => (
|
|
541
|
+
<TableRow key={a.id}>
|
|
542
|
+
<TableCell>
|
|
543
|
+
<p className="font-medium truncate max-w-xs">
|
|
544
|
+
{a.title}
|
|
545
|
+
</p>
|
|
546
|
+
</TableCell>
|
|
547
|
+
<TableCell>
|
|
548
|
+
<SeverityBadge severity={a.severity} />
|
|
549
|
+
</TableCell>
|
|
550
|
+
<TableCell>
|
|
551
|
+
<StatusBadge announcement={a} />
|
|
552
|
+
</TableCell>
|
|
553
|
+
<TableCell>
|
|
554
|
+
<DisplayModeIcon mode={a.displayMode} />
|
|
555
|
+
</TableCell>
|
|
556
|
+
<TableCell>
|
|
557
|
+
<VisibilityIcon visibility={a.visibility} />
|
|
558
|
+
</TableCell>
|
|
559
|
+
<TableCell>
|
|
560
|
+
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
|
561
|
+
<Clock className="h-3 w-3" />
|
|
562
|
+
<span>
|
|
563
|
+
{formatDistanceToNow(new Date(a.createdAt), {
|
|
564
|
+
addSuffix: true,
|
|
565
|
+
})}
|
|
566
|
+
</span>
|
|
567
|
+
</div>
|
|
568
|
+
</TableCell>
|
|
569
|
+
<TableCell>
|
|
570
|
+
<div className="flex gap-1">
|
|
571
|
+
<Button
|
|
572
|
+
variant="ghost"
|
|
573
|
+
size="sm"
|
|
574
|
+
onClick={() => handleEdit(a)}
|
|
575
|
+
>
|
|
576
|
+
<Edit2 className="h-4 w-4" />
|
|
577
|
+
</Button>
|
|
578
|
+
<Button
|
|
579
|
+
variant="ghost"
|
|
580
|
+
size="sm"
|
|
581
|
+
onClick={() => setDeleteId(a.id)}
|
|
582
|
+
>
|
|
583
|
+
<Trash2 className="h-4 w-4 text-destructive" />
|
|
584
|
+
</Button>
|
|
585
|
+
</div>
|
|
586
|
+
</TableCell>
|
|
587
|
+
</TableRow>
|
|
588
|
+
))}
|
|
589
|
+
</TableBody>
|
|
590
|
+
</Table>
|
|
591
|
+
)}
|
|
592
|
+
</CardContent>
|
|
593
|
+
</Card>
|
|
594
|
+
|
|
595
|
+
<AnnouncementEditor
|
|
596
|
+
open={editorOpen}
|
|
597
|
+
onOpenChange={setEditorOpen}
|
|
598
|
+
announcement={editingAnnouncement}
|
|
599
|
+
onSave={handleSave}
|
|
600
|
+
/>
|
|
601
|
+
|
|
602
|
+
<ConfirmationModal
|
|
603
|
+
isOpen={!!deleteId}
|
|
604
|
+
onClose={() => setDeleteId(undefined)}
|
|
605
|
+
title="Delete Announcement"
|
|
606
|
+
message="Are you sure you want to delete this announcement? This action cannot be undone."
|
|
607
|
+
confirmText="Delete"
|
|
608
|
+
variant="danger"
|
|
609
|
+
onConfirm={handleDelete}
|
|
610
|
+
isLoading={deleteMutation.isPending}
|
|
611
|
+
/>
|
|
612
|
+
</PageLayout>
|
|
613
|
+
);
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
export const AnnouncementManagePage = wrapInSuspense(
|
|
617
|
+
AnnouncementManageContent,
|
|
618
|
+
);
|