@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 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
+ );
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }